import 'package:sqflite/sqflite.dart'; import 'package:uuid/uuid.dart'; import '../../core/security/pin_hasher.dart'; import '../../domain/entities/store_account.dart'; import 'app_database.dart'; /// Raised when a staff change would leave the terminal unusable or unowned. class StaffException implements Exception { const StaffException(this.message); final String message; @override String toString() => message; } /// Who can sign in at this till. /// /// The PIN is never stored, only a PBKDF2 hash and its salt — so a stolen /// database file does not hand over the terminal, and neither does an unzipped /// APK, which is what the previous hardcoded literals did. class StaffDao { const StaffDao(this._db); final Database _db; static const _uuid = Uuid(); /// The accounts a shop starts with. /// /// Deliberately not 1234/2345/3456: those are the first thing anyone tries, /// and [_assertPinIsAcceptable] refuses them for exactly that reason — a /// seed the rule itself would reject is not a defensible default. /// /// They are still known values in source, which is why every one is flagged /// [StaffUser.mustChangePin]. They get a shop trading on day one and are /// replaced at first sign-in, rather than becoming the permanent credentials /// the way the old hardcoded PINs did. static const seedAccounts = [ (name: 'Suriya', role: StaffRole.admin, pin: '4821'), (name: 'Divya', role: StaffRole.manager, pin: '5093'), (name: 'Rahul', role: StaffRole.cashier, pin: '6274'), ]; /// Creates the starting accounts the first time a terminal runs. /// /// Idempotent: a terminal that already has staff is left alone, so an upgrade /// never resurrects a deleted account or resets a PIN someone chose. Future seedIfEmpty() async { final existing = await _db.rawQuery( 'SELECT COUNT(*) AS c FROM ${Tables.staff}', ); if ((existing.first['c']! as int) > 0) return; for (final account in seedAccounts) { await create( name: account.name, role: account.role, pin: account.pin, mustChangePin: true, ); } } Future> all({bool includeInactive = false}) async { final rows = await _db.query( Tables.staff, where: includeInactive ? null : 'is_active = 1', orderBy: 'created_at ASC', ); return rows.map(_fromRow).toList(); } Future findById(String id) async { final rows = await _db.query( Tables.staff, where: 'id = ?', whereArgs: [id], limit: 1, ); return rows.isEmpty ? null : _fromRow(rows.first); } /// Checks a PIN and returns whose it is. /// /// Every active account is tried, because a cashier types only a PIN — there /// is no username at the till. Returns null on no match, without saying /// whether the PIN was close. Future authenticate(String pin) async { final rows = await _db.query(Tables.staff, where: 'is_active = 1'); for (final row in rows) { final matches = PinHasher.verify( pin, salt: row['pin_salt']! as String, hash: row['pin_hash']! as String, ); if (matches) return _fromRow(row); } return null; } Future create({ required String name, required StaffRole role, required String pin, bool mustChangePin = false, }) async { _assertPinIsAcceptable(pin); final trimmed = name.trim(); if (trimmed.isEmpty) { throw const StaffException('A staff member needs a name.'); } // Two people sharing a PIN would make the till attribute bills to whichever // row happened to be checked first. if (await authenticate(pin) != null) { throw const StaffException( 'Another staff member already uses that PIN. Choose a different one.', ); } final salt = PinHasher.newSalt(); final now = DateTime.now().millisecondsSinceEpoch; final id = _uuid.v4(); await _db.insert(Tables.staff, { 'id': id, 'name': trimmed, 'role': role.name, 'pin_hash': PinHasher.hash(pin, salt), 'pin_salt': salt, 'must_change_pin': mustChangePin ? 1 : 0, 'is_active': 1, 'created_at': now, 'updated_at': now, }); return StaffUser( id: id, name: trimmed, role: role, mustChangePin: mustChangePin, ); } Future updateDetails({ required String id, String? name, StaffRole? role, }) async { if (role != null) await _assertNotLastAdmin(id, newRole: role); await _db.update( Tables.staff, { if (name != null) 'name': name.trim(), if (role != null) 'role': role.name, 'updated_at': DateTime.now().millisecondsSinceEpoch, }, where: 'id = ?', whereArgs: [id], ); } /// Sets a new PIN. [mustChangePin] is for an admin resetting someone else's; /// a person choosing their own clears the flag. Future setPin( String id, String pin, { bool mustChangePin = false, }) async { _assertPinIsAcceptable(pin); final owner = await authenticate(pin); if (owner != null && owner.id != id) { throw const StaffException( 'Another staff member already uses that PIN. Choose a different one.', ); } final salt = PinHasher.newSalt(); await _db.update( Tables.staff, { 'pin_hash': PinHasher.hash(pin, salt), 'pin_salt': salt, 'must_change_pin': mustChangePin ? 1 : 0, 'updated_at': DateTime.now().millisecondsSinceEpoch, }, where: 'id = ?', whereArgs: [id], ); } /// Deactivates rather than deletes. /// /// Bills carry the cashier's name, and reports are settled against it. A hard /// delete would leave yesterday's takings attributed to nobody. Future deactivate(String id) async { await _assertNotLastAdmin(id, deactivating: true); await _db.update( Tables.staff, { 'is_active': 0, 'updated_at': DateTime.now().millisecondsSinceEpoch, }, where: 'id = ?', whereArgs: [id], ); } Future reactivate(String id) async { await _db.update( Tables.staff, { 'is_active': 1, 'updated_at': DateTime.now().millisecondsSinceEpoch, }, where: 'id = ?', whereArgs: [id], ); } // ------------------------------------------------------------- Internals static void _assertPinIsAcceptable(String pin) { if (pin.length < 4 || int.tryParse(pin) == null) { throw const StaffException('A PIN must be at least four digits.'); } // Not security theatre: on a keypad behind a counter these are the ones a // queue can read off the operator's hand. const tooObvious = {'0000', '1111', '2222', '3333', '4444', '5555', '6666', '7777', '8888', '9999', '1234', '4321', '0123',}; if (tooObvious.contains(pin)) { throw const StaffException( 'That PIN is too easy to guess from across the counter. ' 'Choose another.', ); } } /// A till with no admin cannot be administered — including to make someone an /// admin again. Recovering from it means editing the database by hand. Future _assertNotLastAdmin( String id, { StaffRole? newRole, bool deactivating = false, }) async { final target = await findById(id); if (target == null || target.role != StaffRole.admin) return; final losingAdmin = deactivating || (newRole != StaffRole.admin); if (!losingAdmin) return; final admins = await _db.rawQuery( 'SELECT COUNT(*) AS c FROM ${Tables.staff} ' "WHERE role = 'admin' AND is_active = 1", ); if ((admins.first['c']! as int) <= 1) { throw const StaffException( 'This is the only admin left. Promote someone else first, or the ' 'terminal cannot be administered at all.', ); } } static StaffUser _fromRow(Map row) => StaffUser( id: row['id']! as String, name: row['name']! as String, role: StaffRole.values.byName(row['role']! as String), mustChangePin: (row['must_change_pin'] as int? ?? 0) == 1, isActive: (row['is_active'] as int? ?? 1) == 1, ); }