This commit is contained in:
2026-08-07 14:34:25 +05:30
parent 829e5a8188
commit 0988d39d8b
19 changed files with 850 additions and 932 deletions

View File

@@ -25,10 +25,6 @@ class StaffDao {
final Database _db;
/// Exposed for [StaffImport], which lives in this file and is part of this
/// type in everything but syntax — an extension cannot see a private field.
Database get db => _db;
static const _uuid = Uuid();
/// The accounts a shop starts with.
@@ -150,6 +146,87 @@ class StaffDao {
);
}
/// Writes a staff member the back office owns, creating or updating the row.
///
/// Deliberately skips [_assertPinIsAcceptable] and the duplicate-PIN check.
/// Those rules exist to stop *this terminal* from accepting a weak PIN
/// someone typed at the counter; they are not this terminal's to enforce on
/// a list the back office has already published. Applying them here would
/// mean a shop whose head office issued `1111` simply never receives its
/// staff, and the till falls back to seeded demo accounts — a worse outcome
/// than a guessable PIN.
///
/// [id] is derived from the server's `user_id` rather than minted, so a
/// second sign-in updates the same row instead of duplicating the person.
///
/// A null [pin] leaves an existing PIN alone, and on a new row stores an
/// unusable hash: the person appears on the staff list and can be attributed
/// bills, but nothing typed at the keypad will ever match them. That is the
/// honest representation of "the back office did not give us their PIN".
Future<StaffUser> upsertFromServer({
required String id,
required String name,
required StaffRole role,
String? pin,
bool isActive = true,
}) async {
final trimmed = name.trim().isEmpty ? 'Staff' : name.trim();
final now = DateTime.now().millisecondsSinceEpoch;
final existing = await findById(id);
// Minted once and reused for both columns. PinHasher.newSalt() is random,
// so calling it twice in one statement would store a hash the stored salt
// cannot reproduce.
final salt = PinHasher.newSalt();
if (existing != null) {
await _db.update(
Tables.staff,
{
'name': trimmed,
'role': role.name,
'is_active': isActive ? 1 : 0,
if (pin != null) ...{
'pin_hash': PinHasher.hash(pin, salt),
'pin_salt': salt,
'must_change_pin': 0,
},
'updated_at': now,
},
where: 'id = ?',
whereArgs: [id],
);
return StaffUser(id: id, name: trimmed, role: role, isActive: isActive);
}
// No PIN from the server means no PIN that can ever be entered: hashing a
// random value is how that is stored, rather than a sentinel a future
// reader might treat as "any PIN accepted".
final secret = pin ?? _uuid.v4();
await _db.insert(
Tables.staff,
{
'id': id,
'name': trimmed,
'role': role.name,
'pin_hash': PinHasher.hash(secret, salt),
'pin_salt': salt,
'must_change_pin': 0,
'is_active': isActive ? 1 : 0,
'created_at': now,
'updated_at': now,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
return StaffUser(id: id, name: trimmed, role: role, isActive: isActive);
}
/// A stable local id for a back-office user, so a second sign-in updates the
/// same row instead of duplicating the person.
static String serverId(int userId) => 'srv-$userId';
Future<void> updateDetails({
required String id,
String? name,
@@ -281,93 +358,3 @@ class StaffDao {
isActive: (row['is_active'] as int? ?? 1) == 1,
);
}
/// Replaces the terminal's staff with what the back office says.
///
/// The back office is the source of truth for who works at a shop, and this is
/// where that becomes true rather than aspirational. It exists because the
/// alternative — three names and three PINs compiled into the app — meant every
/// install of a build shared the same three logins, readable by anyone with the
/// APK.
///
/// Three things happen, and the second is the one that matters:
///
/// 1. every person the back office named is written, keyed on their user id so
/// a re-sync updates rather than duplicates;
/// 2. **the seeded accounts are deactivated**, so the moment a shop has real
/// staff the built-in PINs stop working — without this the hardcoded
/// logins would survive alongside the real ones for ever; and
/// 3. anyone previously imported who is no longer named is deactivated too,
/// because a leaver removed in the back office must lose the till.
///
/// Deactivated, never deleted. Bills carry the cashier's name and shifts are
/// settled against it, so a hard delete would orphan a day's takings.
///
/// Does nothing at all when [members] is empty. That is the common case today —
/// most outlets have no staff recorded — and wiping a working till's logins
/// because the back office has not been filled in yet would close a shop.
extension StaffImport on StaffDao {
Future<int> replaceFromBackOffice(List<StaffImportRecord> members) async {
if (members.isEmpty) return 0;
final now = DateTime.now().millisecondsSinceEpoch;
final imported = <String>{};
for (final member in members) {
final pin = member.pin.trim();
// A blank or malformed PIN cannot be signed in with. Skipped rather than
// written, so the till does not show a name nobody can use.
if (pin.length < 4 || int.tryParse(pin) == null) continue;
final salt = PinHasher.newSalt();
imported.add(member.localId);
await db.insert(
Tables.staff,
{
'id': member.localId,
'name': member.name.isEmpty ? 'Staff ${member.localId}' : member.name,
'role': member.role.name,
'pin_hash': PinHasher.hash(pin, salt),
'pin_salt': salt,
// Not flagged for change: this PIN was set by the shop in the back
// office, so it is already theirs. The flag is for the seeds.
'must_change_pin': 0,
'is_active': 1,
'created_at': now,
'updated_at': now,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
// Nothing usable came back — leave the till exactly as it was rather than
// stranding it with no way to sign in.
if (imported.isEmpty) return 0;
final placeholders = List.filled(imported.length, '?').join(',');
await db.update(
Tables.staff,
{'is_active': 0, 'updated_at': now},
where: 'id NOT IN ($placeholders)',
whereArgs: imported.toList(),
);
return imported.length;
}
}
/// One person to import, already mapped onto the till's own role vocabulary.
class StaffImportRecord {
const StaffImportRecord({
required this.localId,
required this.name,
required this.role,
required this.pin,
});
final String localId;
final String name;
final StaffRole role;
final String pin;
}