Take staff from the back office, and let the seeded PINs die when it has any
Suriya/4821, Divya/5093, Rahul/6274 were compiled into the app — the same three logins on every install, readable by anyone with the APK, and unreplaceable. Sign-in now imports the outlet's real staff and deactivates everything it didn't import, so the built-in PINs stop working the moment a shop has anyone recorded. That deactivation is the point: merging would have left the hardcoded logins alive alongside the real ones for ever. The seeds stay, and that is not a hedge. Only 116 of 596 accounts on the platform have a PIN set, and outlet 1135 — the one this build ships pointed at — has none at all. Deleting them would hand 33 of 34 tenants a till nobody can sign in to. So: back office first, local database once synced, seeds only when there is nothing else. Rows are keyed on the back office user id, so a re-sync updates one account rather than creating a second. A leaver removed upstream loses the till on the next sign-in. Accounts are deactivated rather than deleted, because bills carry the cashier's name and shifts settle against it. An import that writes nobody is treated exactly like an empty answer — a back office full of `pin = 0` rows must not deactivate the seeds and strand the counter. That is a real shape in the data, not a hypothetical. An imported PIN is not flagged for change; the shop already chose it. The flag belongs to the seeds, which everyone shares. Role names are mapped by name and fall back to cashier. `app_roles` holds six rows for four roles — Admin and Manager appear twice each — and most accounts carry a roleid absent from the table entirely, so an unrecognised role must not quietly become an admin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,10 @@ 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.
|
||||
@@ -277,3 +281,93 @@ 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user