import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── /// WHO A RECORD BELONGS TO /// /// Miler runs two operations off one app and one login — a milk-man round and /// a logistics day — and it stores finished work, skipped work, carried bags /// and released orders in SharedPreferences. Every one of those keys was /// **global**: `completed_bookings`, `skipped_bookings`, /// `collected_order_ids`, `out_for_delivery_order_ids`. One phone, one key, /// whoever wrote last. /// /// That is three leaks in one: /// /// • **Rider → rider.** Log out, log in as somebody else, and yesterday's /// completed stops are sitting in the new rider's Activity. /// • **Line → line.** A tenant switch moves the app from a round to a /// logistics day; the milk-run's delivered lunches stayed behind in the /// logistics history. /// • **Tenant → tenant.** Same shape, one level up. /// /// ── Why identity and not a label ── /// /// The tempting fix is to filter Activity on something visible — a kitchen /// name, the word "Milk", the tab's title. All of those are *display strings*: /// they are localisable, they are chosen by hub staff, and two tenants can /// legitimately use the same one. Ownership has to come from identity the /// session actually proves: /// /// **rider** `userid` — from the login response, held in prefs /// **tenant** `tenantid` — from the JWT claim, signed by the server /// **line** [ServiceLine] — the operation the profile resolves to /// /// A [WorkScope] is those three together, and it is the only thing allowed to /// decide which records a screen may see. /// ───────────────────────────────────────────────────────────────────────── @immutable class WorkScope { final int userId; final int tenantId; final ServiceLine line; const WorkScope({ required this.userId, required this.tenantId, required this.line, }); /// The scope of the session running right now. /// /// Reads the rider from prefs and the tenant from the token's claim — the /// same two sources the rest of the app authenticates with — and takes the /// line from the resolved profile. Never throws: an unreadable session /// yields the [anonymous] scope, whose records are visible to nobody but /// itself. static Future current() async { try { final prefs = await SharedPreferences.getInstance(); final raw = prefs.get('userid'); final userId = raw is int ? raw : int.tryParse(raw?.toString() ?? '') ?? 0; final tenantId = await ApiConfig.storedTenantId(); return WorkScope( userId: userId, tenantId: tenantId, line: ServiceProfile.active.line, ); } catch (e) { debugPrint('[SCOPE] could not resolve the session scope: $e'); return WorkScope( userId: 0, tenantId: 0, line: ServiceProfile.active.line, ); } } /// A signed-out or unreadable session. Deliberately still a real scope /// rather than null: code paths that run before login write to their own /// drawer instead of into the last rider's. static WorkScope get anonymous => WorkScope(userId: 0, tenantId: 0, line: ServiceProfile.active.line); /// The suffix that turns a store key into this scope's key. /// /// `completed_bookings` → `completed_bookings::u38.t13.milkMan` /// /// All three parts are in it because all three can change independently: a /// rider can move tenant, a tenant can run either line, and one device can /// see several riders. String get key => 'u$userId.t$tenantId.${line.name}'; /// Scopes a legacy global key. String scoped(String baseKey) => '$baseKey::$key'; /// Does [record] provably belong to this scope? /// /// Used on rows that were written before scoping existed, and as a /// belt-and-braces check on rows read back from a scoped key. A row proves /// ownership by carrying the identity itself — `mileruserid` / `userid` and /// `tenantid` are what the API stamps on assignment and consignment rows. /// /// **Absence is not proof.** A row with no identity on it returns false: it /// might be this rider's and it might be the last one's, and the only safe /// reading of "might" is no. bool owns(Map record) { int intOf(List keys) { for (final k in keys) { final v = record[k]; if (v == null) continue; final n = v is int ? v : int.tryParse(v.toString()); if (n != null && n != 0) return n; } return 0; } final rowUser = intOf(const [ 'mileruserid', 'MilerUserId', 'assignedmileruserid', 'userid', 'scopeuserid', ]); final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']); final rowLine = (record['scopeline'] ?? '').toString(); if (rowUser == 0 && rowTenant == 0 && rowLine.isEmpty) return false; if (rowUser != 0 && rowUser != userId) return false; if (rowTenant != 0 && rowTenant != tenantId) return false; if (rowLine.isNotEmpty && rowLine != line.name) return false; return true; } /// Does [record] prove it belongs to **another** scope? /// /// The mirror of [owns], and deliberately not its negation — they answer /// different questions and treat silence differently: /// /// [owns] "prove this is mine" — no identity ⇒ **false** (drop). /// Used on legacy rows, where attributing the unattributable /// is the leak itself. /// [excludes] "prove this is someone /// else's" — no identity ⇒ **false** (keep). /// Used on rows the API just returned for the authenticated /// session, which are already the rider's by construction and /// mostly carry no identity of their own. Dropping those on /// silence would empty the tab. bool excludes(Map record) { int intOf(List keys) { for (final k in keys) { final v = record[k]; if (v == null) continue; final n = v is int ? v : int.tryParse(v.toString()); if (n != null && n != 0) return n; } return 0; } final rowUser = intOf(const [ 'mileruserid', 'MilerUserId', 'assignedmileruserid', 'scopeuserid', ]); final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']); final rowLine = (record['scopeline'] ?? '').toString(); if (rowUser != 0 && userId != 0 && rowUser != userId) return true; if (rowTenant != 0 && tenantId != 0 && rowTenant != tenantId) return true; if (rowLine.isNotEmpty && rowLine != line.name) return true; return false; } /// Stamps [record] so a later read can prove ownership without a session. /// /// Written at the moment a record is stored, which is the one moment the /// scope is known for certain. Map stamp(Map record) => { ...record, 'scopeuserid': userId, 'scopetenantid': tenantId, 'scopeline': line.name, }; @override bool operator ==(Object other) => other is WorkScope && other.userId == userId && other.tenantId == tenantId && other.line == line; @override int get hashCode => Object.hash(userId, tenantId, line); @override String toString() => 'WorkScope($key)'; }