288 lines
9.3 KiB
Dart
288 lines
9.3 KiB
Dart
import 'package:equatable/equatable.dart';
|
|
|
|
import 'store_account.dart';
|
|
|
|
/// One outlet the signed-in account is allowed to work.
|
|
///
|
|
/// A supervisor at a single-shop tenant gets one entry; a multi-outlet account
|
|
/// gets the list, which is what an outlet picker would be built from.
|
|
class SessionLocation extends Equatable {
|
|
const SessionLocation({
|
|
required this.locationId,
|
|
required this.locationName,
|
|
required this.address,
|
|
required this.city,
|
|
required this.status,
|
|
});
|
|
|
|
final int locationId;
|
|
final String locationName;
|
|
final String address;
|
|
final String city;
|
|
final String status;
|
|
|
|
bool get isActive => status.toLowerCase() == 'active';
|
|
|
|
factory SessionLocation.fromJson(Map<String, Object?> json) =>
|
|
SessionLocation(
|
|
locationId: _asInt(json['location_id']),
|
|
locationName: _asString(json['location_name']),
|
|
address: _asString(json['address']),
|
|
city: _asString(json['city']),
|
|
status: _asString(json['status']),
|
|
);
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'location_id': locationId,
|
|
'location_name': locationName,
|
|
'address': address,
|
|
'city': city,
|
|
'status': status,
|
|
};
|
|
|
|
@override
|
|
List<Object?> get props => [locationId, locationName, address, city, status];
|
|
}
|
|
|
|
/// A person the back office says may work this terminal.
|
|
///
|
|
/// The `pin` the server returns is in the clear. It is kept because switching
|
|
/// operators at the till is a PIN entry and nothing else, but it is the reason
|
|
/// [PosSession] is written to the platform keystore rather than to SQLite —
|
|
/// and it is worth pushing the back office to return a hash instead.
|
|
class SessionStaff extends Equatable {
|
|
const SessionStaff({
|
|
required this.userId,
|
|
required this.fullName,
|
|
required this.role,
|
|
required this.pin,
|
|
required this.status,
|
|
});
|
|
|
|
final int userId;
|
|
final String fullName;
|
|
final String role;
|
|
final String pin;
|
|
final String status;
|
|
|
|
bool get isActive => status.toLowerCase() == 'active';
|
|
|
|
factory SessionStaff.fromJson(Map<String, Object?> json) => SessionStaff(
|
|
userId: _asInt(json['user_id']),
|
|
fullName: _asString(json['full_name']),
|
|
role: _asString(json['role']),
|
|
pin: _asString(json['pin']),
|
|
status: _asString(json['status']),
|
|
);
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'user_id': userId,
|
|
'full_name': fullName,
|
|
'role': role,
|
|
'pin': pin,
|
|
'status': status,
|
|
};
|
|
|
|
@override
|
|
List<Object?> get props => [userId, fullName, role, pin, status];
|
|
}
|
|
|
|
/// Everything `POST /pos/login` answered with, plus the account it was issued
|
|
/// to.
|
|
///
|
|
/// This is the whole session: the bearer token every later call needs, who is
|
|
/// signed in, and which outlet the terminal is now trading as. It is persisted
|
|
/// verbatim so a restart does not force a fresh sign-in, and dropped entirely
|
|
/// on sign-out.
|
|
class PosSession extends Equatable {
|
|
const PosSession({
|
|
required this.token,
|
|
required this.authname,
|
|
required this.userId,
|
|
required this.fullName,
|
|
required this.roleId,
|
|
required this.role,
|
|
required this.tenantId,
|
|
required this.tenantName,
|
|
required this.storeId,
|
|
required this.locationId,
|
|
required this.locationName,
|
|
required this.address,
|
|
required this.gstin,
|
|
required this.phone,
|
|
this.expiresAt,
|
|
this.canManageStaff = false,
|
|
this.locations = const [],
|
|
this.staff = const [],
|
|
});
|
|
|
|
/// Bearer token for every subsequent call. Never logged, never printed.
|
|
final String token;
|
|
|
|
/// The credential this session was opened with. Kept only so the login
|
|
/// screen can pre-fill it on the next shift.
|
|
final String authname;
|
|
|
|
final int userId;
|
|
final String fullName;
|
|
|
|
/// Numeric role from the back office (7 = Supervisor on this tenant).
|
|
///
|
|
/// Recorded, but never the thing that decides what the terminal opens — see
|
|
/// [isCashier]. Ids are tenant configuration and can be renumbered; the role
|
|
/// name is the stable contract.
|
|
final int roleId;
|
|
|
|
/// Role name as the server spells it — `Supervisor`, `Cashier`, `Admin`.
|
|
final String role;
|
|
|
|
final bool canManageStaff;
|
|
|
|
final int tenantId;
|
|
final String tenantName;
|
|
|
|
/// The outlet, as a string, matching what the sync topics are namespaced on.
|
|
final String storeId;
|
|
final int locationId;
|
|
final String locationName;
|
|
|
|
/// Printed on every invoice, so these come from the back office rather than
|
|
/// from anything typed into this terminal.
|
|
final String address;
|
|
final String gstin;
|
|
final String phone;
|
|
|
|
final DateTime? expiresAt;
|
|
final List<SessionLocation> locations;
|
|
final List<SessionStaff> staff;
|
|
|
|
/// Roles that get the full shell: catalogue import, promos, settings, staff.
|
|
static const Set<String> adminRoles = {
|
|
'admin',
|
|
'administrator',
|
|
'owner',
|
|
'supervisor',
|
|
'manager',
|
|
'store manager',
|
|
};
|
|
|
|
/// Whether this session is locked down to the billing screen.
|
|
///
|
|
/// Anything not in [adminRoles] lands here, including a role this build has
|
|
/// never seen. A new back-office role must not silently inherit catalogue
|
|
/// and settings access because nobody remembered to list it — the failure
|
|
/// should be "the supervisor sees a till", which someone reports in a
|
|
/// minute, not "the cashier can edit prices", which nobody notices.
|
|
bool get isCashier => !adminRoles.contains(role.trim().toLowerCase());
|
|
|
|
/// How this account maps onto the terminal's own permission model.
|
|
///
|
|
/// Two values, not four: the shell has exactly two shapes, and every
|
|
/// non-cashier role the back office issues is expected to be able to import
|
|
/// products and edit the store's details — which is what `StaffRole.admin`
|
|
/// unlocks locally.
|
|
StaffRole get staffRole => isCashier ? StaffRole.cashier : StaffRole.admin;
|
|
|
|
/// The operator, in the shape the rest of the app already speaks.
|
|
StaffUser get user => StaffUser(
|
|
id: '$userId',
|
|
name: fullName,
|
|
role: staffRole,
|
|
// The back office owns this credential now, so the terminal never
|
|
// forces a PIN change on an account it did not seed.
|
|
mustChangePin: false,
|
|
);
|
|
|
|
bool get isExpired =>
|
|
expiresAt != null && !DateTime.now().toUtc().isBefore(expiresAt!.toUtc());
|
|
|
|
/// Reads the `details` object of a successful login response.
|
|
factory PosSession.fromDetails(
|
|
Map<String, Object?> details, {
|
|
required String authname,
|
|
}) =>
|
|
PosSession(
|
|
token: _asString(details['token']),
|
|
authname: authname,
|
|
userId: _asInt(details['user_id']),
|
|
fullName: _asString(details['full_name']),
|
|
roleId: _asInt(details['role_id']),
|
|
role: _asString(details['role']),
|
|
canManageStaff: _asBool(details['can_manage_staff']),
|
|
tenantId: _asInt(details['tenant_id']),
|
|
tenantName: _asString(details['tenant_name']),
|
|
storeId: _asString(details['store_id']),
|
|
locationId: _asInt(details['location_id']),
|
|
locationName: _asString(details['location_name']),
|
|
address: _asString(details['address']),
|
|
gstin: _asString(details['gstin']),
|
|
phone: _asString(details['phone']),
|
|
expiresAt: DateTime.tryParse(_asString(details['expires_at'])),
|
|
locations: _asList(details['locations'], SessionLocation.fromJson),
|
|
staff: _asList(details['staff'], SessionStaff.fromJson),
|
|
);
|
|
|
|
/// Round-trips through [toJson], for reading back out of the keystore.
|
|
factory PosSession.fromJson(Map<String, Object?> json) =>
|
|
PosSession.fromDetails(json, authname: _asString(json['authname']));
|
|
|
|
Map<String, Object?> toJson() => {
|
|
'token': token,
|
|
'authname': authname,
|
|
'user_id': userId,
|
|
'full_name': fullName,
|
|
'role_id': roleId,
|
|
'role': role,
|
|
'can_manage_staff': canManageStaff,
|
|
'tenant_id': tenantId,
|
|
'tenant_name': tenantName,
|
|
'store_id': storeId,
|
|
'location_id': locationId,
|
|
'location_name': locationName,
|
|
'address': address,
|
|
'gstin': gstin,
|
|
'phone': phone,
|
|
'expires_at': expiresAt?.toUtc().toIso8601String(),
|
|
'locations': locations.map((l) => l.toJson()).toList(),
|
|
'staff': staff.map((s) => s.toJson()).toList(),
|
|
};
|
|
|
|
@override
|
|
List<Object?> get props => [token, userId, roleId, role, locationId];
|
|
|
|
/// Never let a token reach a log line or a crash report.
|
|
@override
|
|
String toString() =>
|
|
'PosSession($fullName, $role, $locationName, expires $expiresAt)';
|
|
}
|
|
|
|
// --------------------------------------------------------------- Decoding
|
|
//
|
|
// Tolerant on purpose. `store_id` arrives as a string and `location_id` as a
|
|
// number for the same outlet, and a field the back office adds later must not
|
|
// crash a till mid-shift.
|
|
|
|
String _asString(Object? value) => value == null ? '' : '$value';
|
|
|
|
int _asInt(Object? value) => switch (value) {
|
|
final int v => v,
|
|
final num v => v.toInt(),
|
|
final String v => int.tryParse(v) ?? 0,
|
|
_ => 0,
|
|
};
|
|
|
|
bool _asBool(Object? value) => switch (value) {
|
|
final bool v => v,
|
|
final num v => v != 0,
|
|
final String v => v.toLowerCase() == 'true' || v == '1',
|
|
_ => false,
|
|
};
|
|
|
|
List<T> _asList<T>(Object? raw, T Function(Map<String, Object?>) decode) {
|
|
if (raw is! List) return const [];
|
|
return raw
|
|
.whereType<Map<String, Object?>>()
|
|
.map(decode)
|
|
.toList(growable: false);
|
|
}
|