Files
doormile_milderapp/lib/data/work_scope.dart
Thiru-tenext d612916fe4 Session expiry, arrival geofence guard, multi-destination stops
Three fixes found by running the app on a real handset against production.

1. An expired token left the app looking signed in and unable to work.
   MilerApi.onUnauthorized was declared and called on every 401 but never
   assigned, so the token was dropped and nothing else happened: the profile
   stayed on disk, logged_out stayed false, and the rider saw his own name over
   a dashboard whose every call returned 401. He reads that as "no work today".
   The teardown now lives in endSession() and both ways out of a session — the
   Log out button and the 401 path — use it.

2. Arrived was written locally even when the rider was not there.
   updateArrivedStatus answers false for three different things and the caller
   treated all of them as "the write did not land", which is only true of one.
   A geofence refusal and a server refusal now stop the rung and hand back the
   reason; a dead network still advances, as it should.

3. A multi-destination customer pickup collapsed onto one stop.
   GET /miler/bookings returns a row per destination once collected, all with
   the same bookingid and reference. Every local store keys on that id, so the
   accepted store deduped two of three drops away and their consignment ids
   were unrecoverable. orderid is now the stop key; bookingreference stays the
   booking's name. Cards show "Stop 2 of 3" and the receiver's own name and
   number rather than the sender's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-18 11:05:40 +05:30

206 lines
8.4 KiB
Dart

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;
const WorkScope({required this.userId, required this.tenantId});
/// 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<WorkScope> 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);
} catch (e) {
debugPrint('[SCOPE] could not resolve the session scope: $e');
return const WorkScope(userId: 0, tenantId: 0);
}
}
/// 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 => const WorkScope(userId: 0, tenantId: 0);
/// 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';
/// Scopes a legacy global key.
String scoped(String baseKey) => '$baseKey::$key';
/// The keys this scope's records used to live under, newest convention first.
///
/// ── Why the line came out of the key ──
///
/// The key used to carry a third part, the rider's service line:
/// `completed_bookings::u38.t13.milkMan`. That was right while a rider was
/// one thing for the life of his account. He is not: one Miler carries meal
/// parcels, logistics collections and customer pickups in the same shift, and
/// keying his records by a line splits one day's work across two drawers —
/// with whichever drawer the app resolved into today being the only one he
/// can see. Work he finished an hour ago disappears because a label changed.
///
/// Rider and tenant still scope it. Those are facts about *who* stored the
/// record, which is what ownership means. The line was a fact about *what he
/// was doing*, which belongs on the work item, not on the drawer.
///
/// Every historical spelling is listed so a rider upgrading mid-shift keeps
/// what he has already done.
Iterable<String> legacyScopedKeys(String baseKey) sync* {
for (final line in ServiceLine.values) {
yield '$baseKey::u$userId.t$tenantId.${line.name}';
}
}
/// 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<String, dynamic> record) {
int intOf(List<String> 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']);
if (rowUser == 0 && rowTenant == 0) return false;
if (rowUser != 0 && rowUser != userId) return false;
if (rowTenant != 0 && rowTenant != tenantId) 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<String, dynamic> record) {
int intOf(List<String> 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']);
if (rowUser != 0 && userId != 0 && rowUser != userId) return true;
if (rowTenant != 0 && tenantId != 0 && rowTenant != tenantId) 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<String, dynamic> stamp(Map<String, dynamic> record) => {
...record,
'scopeuserid': userId,
'scopetenantid': tenantId,
};
@override
bool operator ==(Object other) =>
other is WorkScope &&
other.userId == userId &&
other.tenantId == tenantId;
@override
int get hashCode => Object.hash(userId, tenantId);
@override
String toString() => 'WorkScope($key)';
}