From a9c0ea19a999f811c916e6f77b25157dcd563bd5 Mon Sep 17 00:00:00 2001 From: Carlos Virreira Date: Fri, 24 Jul 2026 17:22:24 +0200 Subject: [PATCH 1/3] feat(companion): local due-back reminders for checked-out bookings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two local notifications per booking checked out from this device — due soon (1h before) and due now — scheduled at checkout and torn down the moment the booking stops being out. Nothing is fire-and-forget: the scheduled set is always re-derived from the server, so reminders can never nag about gear that is already back. Architecture (lib/reminders/): - plan.ts: pure, dependency-free planner (single source of truth for what fires and when; absolute-date triggers so DST cannot shift a reminder). - service.ts: schedule/cancel/reconcile runtime. Every sync re-fetches the booking; 404/403 is authoritative 'gone' (cancels + untracks, healing web deletes and lost workspace access) while network failures leave state untouched for the next reconcile. Map mutations are serialized on a promise queue; the fetch and the interactive permission dialog run OUTSIDE it so a check-in's cancel never waits behind a timeout or an open prompt. Persisting-after-scheduling failures roll the schedules back so storage errors cannot create uncancellable reminders. Pending count stays under iOS's 64-notification cap (future-dated only, furthest-out dropped first, never silently). - notifications-native.ts: lazy guarded require — expo-notifications calls requireNativeModule at module top level, so a static import would crash pre-notifications builds (older dev clients) at startup. - use-booking-reminders.ts: init + foreground reconcile (leaving the foreground marks the debounce stale so every genuine return reconciles) + tap handling with cold/warm dedupe, workspace switch when the reminder belongs to another org, and pushIntoTab-anchored navigation. Wiring: - Schedule on checkout success (scanner fulfil-and-checkout, detail full and partial checkout). Cancel on full check-in, complete partial check-in, cancel, archive, delete, and on sign-out (fetches would 401 forever after it, so tracked records could never heal). - Settings: master 'Booking reminders' toggle mirroring the scan-sound pattern; off cancels everything but keeps the tracked map so on restores via reconcile; a denied OS permission points at device settings. - apiFetch now also returns the HTTP status (additive) so callers can tell an authoritative 404/403 from a network failure. - Permission is asked in context (first checkout / the toggle), never on launch. No app cron: due-times are local notifications scheduled at checkout; the OS delivers them. Needs a new native build (expo-notifications); all logic after that build is plain JS and OTA-patchable. --- apps/companion/app.json | 1 + apps/companion/app/(tabs)/bookings/[id].tsx | 26 + apps/companion/app/(tabs)/scanner.tsx | 14 + apps/companion/app/(tabs)/settings.tsx | 60 +- apps/companion/app/_layout.tsx | 5 + apps/companion/lib/api/client.ts | 23 +- apps/companion/lib/auth-context.tsx | 8 + apps/companion/lib/reminders/index.ts | 26 + .../lib/reminders/notifications-native.ts | 44 ++ apps/companion/lib/reminders/plan.ts | 115 ++++ apps/companion/lib/reminders/service.ts | 561 ++++++++++++++++++ .../lib/reminders/use-booking-reminders.ts | 176 ++++++ apps/companion/package.json | 1 + pnpm-lock.yaml | 95 +++ 14 files changed, 1150 insertions(+), 5 deletions(-) create mode 100644 apps/companion/lib/reminders/index.ts create mode 100644 apps/companion/lib/reminders/notifications-native.ts create mode 100644 apps/companion/lib/reminders/plan.ts create mode 100644 apps/companion/lib/reminders/service.ts create mode 100644 apps/companion/lib/reminders/use-booking-reminders.ts diff --git a/apps/companion/app.json b/apps/companion/app.json index e64b1b675e..228517ec66 100644 --- a/apps/companion/app.json +++ b/apps/companion/app.json @@ -76,6 +76,7 @@ ], "expo-font", "expo-web-browser", + "expo-notifications", "expo-quick-actions", "./plugins/swift-concurrency-fix", "@sentry/react-native" diff --git a/apps/companion/app/(tabs)/bookings/[id].tsx b/apps/companion/app/(tabs)/bookings/[id].tsx index 76741c843b..f14e4bc378 100644 --- a/apps/companion/app/(tabs)/bookings/[id].tsx +++ b/apps/companion/app/(tabs)/bookings/[id].tsx @@ -30,6 +30,7 @@ import { type CheckinDisposition, } from "@/lib/api"; import { useOrg } from "@/lib/org-context"; +import { cancelBookingReminders, syncBookingReminders } from "@/lib/reminders"; import { fontSize, spacing, @@ -244,6 +245,11 @@ export default function BookingDetailScreen() { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); // Mutation changed this booking — force the list to refetch. markBookingsListDirty(); + // Now ONGOING — schedule due-back reminders (interactive: a + // direct user action, so the OS permission prompt may show). + void syncBookingReminders(booking.id, currentOrg.id, { + interactive: true, + }); Alert.alert("Checked Out", `"${booking.name}" is now ongoing.`, [ { text: "OK", onPress: () => fetchBooking() }, ]); @@ -277,6 +283,8 @@ export default function BookingDetailScreen() { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); // Mutation changed this booking — force the list to refetch. markBookingsListDirty(); + // Fully returned — drop the due-back reminders immediately. + void cancelBookingReminders(booking.id); Alert.alert("Complete", `"${booking.name}" is now complete.`, [ { text: "OK", @@ -317,6 +325,11 @@ export default function BookingDetailScreen() { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); // Mutation changed this booking — force the list to refetch. markBookingsListDirty(); + // Fully returned → drop reminders now; partial → gear is still out, and + // if the due time is unchanged the reminders remain correct as-is. + if (data?.isComplete) { + void cancelBookingReminders(booking.id); + } const msg = data?.isComplete ? `All assets checked in. "${booking.name}" is now complete.` : `${data?.checkedInCount ?? "Some"} checked in, ${ @@ -395,6 +408,12 @@ export default function BookingDetailScreen() { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); // Mutation changed this booking — force the list to refetch. markBookingsListDirty(); + // The first partial checkout flips RESERVED → ONGOING; sync re-fetches + // and only schedules when the booking is genuinely out, so calling it + // on every partial success is safe and idempotent. + void syncBookingReminders(booking.id, currentOrg.id, { + interactive: true, + }); const msg = data?.isComplete ? `All assets are now checked out for "${booking.name}".` : `${data?.checkedOutCount ?? "Some"} checked out, ${ @@ -546,6 +565,8 @@ export default function BookingDetailScreen() { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); // Mutation changed this booking — force the list to refetch. markBookingsListDirty(); + // Cancelled bookings have nothing due back. + void cancelBookingReminders(booking.id); fetchBooking(); }, }, @@ -576,6 +597,8 @@ export default function BookingDetailScreen() { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); // Mutation changed this booking — force the list to refetch. markBookingsListDirty(); + // Archived = administratively closed; stop reminding. + void cancelBookingReminders(booking.id); fetchBooking(); }, }, @@ -607,6 +630,9 @@ export default function BookingDetailScreen() { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); // Mutation changed this booking — force the list to refetch. markBookingsListDirty(); + // Gone from the server — reminders must go with it (this is why + // cancel is fetch-free: a sync would 404 here). + void cancelBookingReminders(booking.id); // The booking no longer exists — leave the detail screen. router.back(); }, diff --git a/apps/companion/app/(tabs)/scanner.tsx b/apps/companion/app/(tabs)/scanner.tsx index a0093d0a16..a235704777 100644 --- a/apps/companion/app/(tabs)/scanner.tsx +++ b/apps/companion/app/(tabs)/scanner.tsx @@ -21,6 +21,7 @@ import { Ionicons } from "@expo/vector-icons"; import { api } from "@/lib/api"; import { useOrg } from "@/lib/org-context"; import { openShelfWebUrl, pushIntoTab } from "@/lib/navigation"; +import { cancelBookingReminders, syncBookingReminders } from "@/lib/reminders"; import { TeamMemberPicker } from "@/components/team-member-picker"; import { LocationPicker } from "@/components/location-picker"; import type { TeamMember, Location as LocationType } from "@/lib/api"; @@ -1642,6 +1643,13 @@ function ScannerContent() { return; } + // Booking is now ONGOING — schedule its due-back reminders. + // Interactive: this is a direct user action, so the OS + // permission prompt may show here (first checkout only). + void syncBookingReminders(bookingId, currentOrg.id, { + interactive: true, + }); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); playScanSound(); Alert.alert( @@ -1753,6 +1761,12 @@ function ScannerContent() { } : prev ); + // Fully returned → the booking left ONGOING; drop its due-back + // reminders immediately (no fetch — we know it closed here). + // Partial check-ins keep them: gear is still out. + if (result?.isComplete) { + void cancelBookingReminders(bookingId); + } const msg = result?.isComplete ? `All assets checked in! "${ bookingName || "Booking" diff --git a/apps/companion/app/(tabs)/settings.tsx b/apps/companion/app/(tabs)/settings.tsx index e2afb6c691..e9e907df37 100644 --- a/apps/companion/app/(tabs)/settings.tsx +++ b/apps/companion/app/(tabs)/settings.tsx @@ -4,6 +4,7 @@ import { Text, TouchableOpacity, Alert, + Linking, ScrollView, Switch, } from "react-native"; @@ -28,6 +29,7 @@ import { setScanSoundEnabled, playScanSound, } from "@/lib/scan-sound"; +import { loadRemindersPreference, setRemindersEnabled } from "@/lib/reminders"; const appVersion = Constants.expoConfig?.version ?? @@ -52,11 +54,13 @@ export default function SettingsScreen() { const [startPage, setStartPageState] = useState("assets"); const [scanSoundOn, setScanSoundOn] = useState(true); + const [remindersOn, setRemindersOn] = useState(true); - // Load persisted start page and scan sound preference on mount + // Load persisted start page, scan sound, and reminders preferences on mount useEffect(() => { getStartPage().then(setStartPageState); loadScanSoundPreference().then(setScanSoundOn); + loadRemindersPreference().then(setRemindersOn); }, []); const handleStartPageChange = (page: StartPage) => { @@ -301,6 +305,60 @@ export default function SettingsScreen() { + {/* Booking reminders toggle */} + + Reminders + + + + + + Booking reminders + + Notify when checked-out gear is due back + + + + { + Haptics.selectionAsync(); + setRemindersOn(value); + // Persists the choice, cancels/reschedules accordingly, and + // reports whether the OS-level permission is granted. + const granted = await setRemindersEnabled(value); + if (value && !granted) { + Alert.alert( + "Notifications are off", + "Shelf can't show reminders until notifications are allowed in your device settings.", + [ + { text: "Not now", style: "cancel" }, + { + text: "Open Settings", + onPress: () => { + void Linking.openSettings(); + }, + }, + ] + ); + } + }} + trackColor={{ + false: colors.borderLight, + true: colors.primary + "60", + }} + thumbColor={remindersOn ? colors.primary : colors.mutedLight} + accessibilityLabel="Toggle booking reminders" + accessibilityRole="switch" + /> + + + + About diff --git a/apps/companion/app/_layout.tsx b/apps/companion/app/_layout.tsx index 5c41892082..9497a08165 100644 --- a/apps/companion/app/_layout.tsx +++ b/apps/companion/app/_layout.tsx @@ -11,6 +11,7 @@ import { OfflineBanner } from "@/components/offline-banner"; import AnimatedSplash from "@/components/animated-splash"; import { useDeepLinkHandler } from "@/lib/deep-links"; import { useQuickActions } from "@/lib/quick-actions"; +import { useBookingReminders } from "@/lib/reminders"; import { getStartPage, getStartPageRoute } from "@/lib/start-page"; import { preloadScanSound } from "@/lib/scan-sound"; import { initSentry } from "@/lib/sentry"; @@ -57,6 +58,10 @@ function RootLayoutNav() { // Register 3D Touch / long-press quick actions (home screen shortcuts) useQuickActions(); + // Booking due-back reminders: init presentation, reconcile on foreground, + // deep-link reminder taps to their booking. + useBookingReminders(); + // Redirect after splash finishes and auth state is known. useEffect(() => { if (isLoading || !splashComplete) return; diff --git a/apps/companion/lib/api/client.ts b/apps/companion/lib/api/client.ts index bacff30057..d84e25ca9a 100644 --- a/apps/companion/lib/api/client.ts +++ b/apps/companion/lib/api/client.ts @@ -78,12 +78,16 @@ export type ApiFetchOptions = RequestInit & { retry?: boolean }; * - Returns structured { data, error } -- never throws. * - Detects 401/session-expired and notifies global auth listeners. * - Enforces a request timeout to avoid hanging on slow networks. + * - `status` carries the HTTP status when a response was received at all, + * letting callers tell an authoritative 404/403 ("gone") apart from a + * network/timeout failure (`status` undefined). Additive — existing + * `{ data, error }` destructuring is unaffected. */ export async function apiFetch( path: string, options: ApiFetchOptions = {}, _retryCount = 0 -): Promise<{ data: T | null; error: string | null }> { +): Promise<{ data: T | null; error: string | null; status?: number }> { // Declared outside try so catch block can read it let timedOut = false; @@ -134,9 +138,17 @@ export async function apiFetch( json = text ? JSON.parse(text) : null; } catch { if (!response.ok) { - return { data: null, error: `Server error (${response.status})` }; + return { + data: null, + error: `Server error (${response.status})`, + status: response.status, + }; } - return { data: null, error: "Invalid response from server" }; + return { + data: null, + error: "Invalid response from server", + status: response.status, + }; } if (!response.ok) { @@ -146,6 +158,7 @@ export async function apiFetch( return { data: null, error: "Session expired. Please sign in again.", + status: response.status, }; } // 403 = forbidden → user lacks permission, but session is valid @@ -155,15 +168,17 @@ export async function apiFetch( error: json?.error?.message || "You don't have permission to perform this action.", + status: response.status, }; } return { data: null, error: json?.error?.message || `Request failed (${response.status})`, + status: response.status, }; } - return { data: json as T, error: null }; + return { data: json as T, error: null, status: response.status }; } catch (err) { // Navigation/cleanup abort — silently return null (not an error) if (err instanceof Error && err.name === "AbortError" && !timedOut) { diff --git a/apps/companion/lib/auth-context.tsx b/apps/companion/lib/auth-context.tsx index b647ec3633..37c5992144 100644 --- a/apps/companion/lib/auth-context.tsx +++ b/apps/companion/lib/auth-context.tsx @@ -8,6 +8,10 @@ import { type ReactNode, } from "react"; import type { Session, User } from "@supabase/supabase-js"; +// why: deep import — the ./reminders barrel pulls in the org-context-using +// hook, and org-context imports this file; going straight to the service +// (which only touches the api client) avoids a require cycle. +import { clearAllBookingReminders } from "./reminders/service"; import { supabase } from "./supabase"; type AuthState = { @@ -58,6 +62,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); const signOut = useCallback(async () => { + // Scheduled booking reminders belong to this account's session. After + // sign-out every reconcile fetch would 401 forever, so the tracked + // records could never heal — cancel and forget them all up front. + await clearAllBookingReminders(); await supabase.auth.signOut(); }, []); diff --git a/apps/companion/lib/reminders/index.ts b/apps/companion/lib/reminders/index.ts new file mode 100644 index 0000000000..3480fc8241 --- /dev/null +++ b/apps/companion/lib/reminders/index.ts @@ -0,0 +1,26 @@ +/** + * Booking reminders — public surface (consumed exports only). + * + * Local due-back notifications for bookings checked out from this device: + * scheduled on checkout, cancelled on check-in/cancel/archive/delete and + * sign-out, and re-derived from the server on every app foreground so they + * can never nag about gear that is already back. + * + * The pure planner and the reconcile/init internals are deliberately NOT + * re-exported here — the hook consumes them directly from ./service and + * ./plan, and an unused public surface is just dead API to maintain. + * + * @see {@link file://./plan.ts} pure planner (what fires, when) + * @see {@link file://./service.ts} runtime (schedule/cancel/reconcile) + * @see {@link file://./use-booking-reminders.ts} app wiring hook + */ +export { + cancelBookingReminders, + loadRemindersPreference, + setRemindersEnabled, + syncBookingReminders, +} from "./service"; +export { useBookingReminders } from "./use-booking-reminders"; +// NOTE: clearAllBookingReminders is deliberately not re-exported here — its +// one consumer (auth-context) deep-imports ./service to avoid a require +// cycle through the org-context-using hook this barrel pulls in. diff --git a/apps/companion/lib/reminders/notifications-native.ts b/apps/companion/lib/reminders/notifications-native.ts new file mode 100644 index 0000000000..917e4f369b --- /dev/null +++ b/apps/companion/lib/reminders/notifications-native.ts @@ -0,0 +1,44 @@ +/** + * Lazy, guarded access to the expo-notifications native module. + * + * expo-notifications calls `requireNativeModule(...)` at MODULE TOP LEVEL, + * so a static `import * as Notifications from "expo-notifications"` crashes + * at bundle-evaluation time on any binary built before the module was added + * (e.g. a teammate's older dev client pulling this branch, or an OTA update + * reaching a pre-notifications build). Call-site try/catch cannot help — the + * throw happens before any function runs. + * + * This wrapper defers the require to first use and memoizes the result, so + * builds without the native module degrade to a feature-wide no-op instead + * of a startup crash. Type-only imports of expo-notifications are safe + * anywhere (erased at compile time); RUNTIME access must go through + * {@link getNotifications}. + * + * @see {@link file://./service.ts} the only intended consumer + */ +import type * as ExpoNotifications from "expo-notifications"; + +/** The full expo-notifications API surface, or null when unavailable. */ +export type NotificationsModule = typeof ExpoNotifications; + +/** `undefined` = not yet attempted; `null` = attempted and unavailable. */ +let cached: NotificationsModule | null | undefined; + +/** + * Load expo-notifications on first use. + * + * @returns The module, or null when the native side is absent (older build). + */ +export function getNotifications(): NotificationsModule | null { + if (cached !== undefined) return cached; + try { + // why: a static import evaluates the package's top-level + // requireNativeModule() and crashes pre-notifications builds at startup; + // a guarded require is the only way to degrade gracefully. + // eslint-disable-next-line @typescript-eslint/no-require-imports + cached = require("expo-notifications") as NotificationsModule; + } catch { + cached = null; + } + return cached; +} diff --git a/apps/companion/lib/reminders/plan.ts b/apps/companion/lib/reminders/plan.ts new file mode 100644 index 0000000000..c33f86b250 --- /dev/null +++ b/apps/companion/lib/reminders/plan.ts @@ -0,0 +1,115 @@ +/** + * Booking reminder plan — the pure heart of the local-reminders feature. + * + * Given a booking's identity and due time, decide WHICH reminders should + * exist and WHEN they fire. Everything else in the feature (scheduling, + * cancelling, reconciling) treats this function's output as the single + * source of truth, so every future change to reminder behaviour — new + * reminder types, different lead times, quiet hours — is an edit HERE and + * nowhere else. + * + * Deliberately dependency-free (no expo imports, no storage, no Date.now() + * calls — `now` is a parameter) so it can be unit-tested and reasoned about + * in isolation. + * + * @see {@link file://./service.ts} the runtime that schedules/cancels this plan + */ + +/** Lead time for the "due soon" heads-up before a booking's due instant. */ +export const DUE_SOON_LEAD_MS = 60 * 60 * 1000; // 1 hour + +/** Discriminates the two v1 reminder types. */ +export type ReminderType = "due-soon" | "due-now"; + +/** One reminder to schedule: an absolute fire time plus display strings. */ +export type PlannedReminder = { + type: ReminderType; + /** + * Absolute instant to fire. Always scheduled as a fixed date (never + * calendar components) so DST shifts and timezone travel cannot move it + * relative to the booking's real due instant. + */ + fireAt: Date; + title: string; + body: string; + /** + * Payload delivered on tap, used to deep-link into the booking. Carries + * `orgId` because the booking may belong to a workspace other than the + * one active when the reminder fires — the tap handler switches first. + */ + data: { type: ReminderType; bookingId: string; orgId: string }; +}; + +/** The minimal booking shape the planner needs. */ +export type PlannableBooking = { + id: string; + name: string; + /** ISO due instant (`Booking.to`). Null/empty → no reminders. */ + to: string | null | undefined; + /** Concrete assets on the booking, for the notification body. */ + assetCount?: number; + /** Workspace the booking lives in, embedded in the tap payload. */ + orgId: string; +}; + +/** + * Compute the reminders that should exist for a checked-out booking. + * + * Rules (decided in the launch spec, in this order): + * - No due time → no reminders. + * - Due instant unparsable → no reminders (defensive; server sends ISO). + * - Already past due at planning time → no reminders. The booking is + * already visibly overdue in-app; firing a reminder for a moment that has + * gone reads as noise, not help. + * - Due within the lead window → only "due-now" (a "due soon" whose fire + * time is already in the past must not fire immediately on checkout). + * - Otherwise → "due-soon" at `to - lead` and "due-now" at `to`. + * + * @param booking - The booking to plan for. + * @param now - The current instant (injected for testability). + * @returns Reminders to schedule, soonest first. Possibly empty. + */ +export function computeReminderPlan( + booking: PlannableBooking, + now: Date +): PlannedReminder[] { + if (!booking.to) return []; + + const due = new Date(booking.to); + if (Number.isNaN(due.getTime())) return []; + if (due.getTime() <= now.getTime()) return []; + + const itemsSuffix = formatItemsSuffix(booking.assetCount); + const plan: PlannedReminder[] = []; + + const dueSoonAt = new Date(due.getTime() - DUE_SOON_LEAD_MS); + if (dueSoonAt.getTime() > now.getTime()) { + plan.push({ + type: "due-soon", + fireAt: dueSoonAt, + title: `"${booking.name}" is due back in 1 hour`, + body: itemsSuffix ?? "Tap to open the booking", + data: { type: "due-soon", bookingId: booking.id, orgId: booking.orgId }, + }); + } + + plan.push({ + type: "due-now", + fireAt: due, + title: `"${booking.name}" is due back now`, + body: itemsSuffix ? `${itemsSuffix} still out` : "Tap to open the booking", + data: { type: "due-now", bookingId: booking.id, orgId: booking.orgId }, + }); + + return plan; +} + +/** + * "6 items" / "1 item" body fragment, or null when the count is unknown or + * zero (a zero-asset booking can still be checked out via model requests, + * but "0 items" in a notification reads broken). + */ +function formatItemsSuffix(assetCount: number | undefined): string | null { + if (!assetCount || assetCount < 1) return null; + return assetCount === 1 ? "1 item" : `${assetCount} items`; +} diff --git a/apps/companion/lib/reminders/service.ts b/apps/companion/lib/reminders/service.ts new file mode 100644 index 0000000000..2f0b0334d9 --- /dev/null +++ b/apps/companion/lib/reminders/service.ts @@ -0,0 +1,561 @@ +/** + * Booking reminders runtime — schedules, cancels, and reconciles the local + * due-back notifications planned by {@link file://./plan.ts}. + * + * Design rules (from the launch spec, hardened by review): + * - The scheduled set is always DERIVED: every sync re-fetches the booking + * and rebuilds its reminders from `computeReminderPlan`, so state can + * drift (booking returned on web, extended, cancelled) but self-corrects + * toward the server's truth on the next sync/reconcile. + * - A 404/403 on that fetch is an AUTHORITATIVE "gone" (deleted booking, or + * the user lost workspace access) and cancels + untracks immediately. + * Network/timeout/5xx failures leave state untouched — a stale reminder + * the next reconcile removes beats silently dropping a real one. + * - Absolute-date triggers only — never calendar components — so DST or + * timezone changes cannot move a reminder relative to the real due + * instant. + * - Native access goes through {@link file://./notifications-native.ts}: a + * build without the expo-notifications module no-ops instead of crashing. + * - No background execution: the OS delivers scheduled notifications on its + * own. Reconcile runs opportunistically on app foreground. + * - Slow work (the fetch, the interactive permission dialog) happens OUTSIDE + * the serialization queue, so a check-in's cancel is never stuck behind an + * offline fetch timeout or an open permission prompt. + * + * Persistence mirrors the existing lib/scan-sound.ts pattern: AsyncStorage + * keys with a module-level cached flag, "true"/"false" string values. + * + * @see {@link file://./plan.ts} the pure planner (single source of truth) + * @see {@link file://./use-booking-reminders.ts} the app-level wiring hook + */ +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { Platform } from "react-native"; + +import { api } from "@/lib/api"; + +import { getNotifications } from "./notifications-native"; +import { computeReminderPlan, type ReminderType } from "./plan"; + +/** Master toggle. Anything other than the literal "false" means enabled. */ +const ENABLED_KEY = "shelf_booking_reminders_enabled"; +/** JSON map of tracked bookings → their scheduled notification ids. */ +const TRACKED_KEY = "shelf_booking_reminders_tracked_v1"; +/** Android requires a channel; iOS ignores it. */ +const ANDROID_CHANNEL_ID = "booking-reminders"; +/** + * iOS silently keeps at most 64 pending local notifications. Stay under it + * with headroom so other features (or the OS) never push us over the edge. + */ +const MAX_SCHEDULED = 60; +/** + * Debounce between reconciles WITHIN one foreground session (rapid + * active/inactive flaps). A real background → foreground transition resets + * this via {@link markReconcileStale}, so the spec's "reconcile on every app + * foreground" holds even when two foregrounds are seconds apart. + */ +const RECONCILE_MIN_INTERVAL_MS = 60_000; + +/** One scheduled OS notification belonging to a tracked booking. */ +type ScheduledReminder = { + notificationId: string; + type: ReminderType; + /** ISO fire instant — used by the cap to keep the soonest-due first. */ + fireAt: string; +}; + +/** A booking checked out from this device that we are reminding about. */ +type TrackedBooking = { + bookingId: string; + /** Needed to re-fetch during reconcile (api calls are org-scoped). */ + orgId: string; + /** Diagnostic snapshot for debugging/inspection; sync re-derives both. */ + name: string; + /** Diagnostic: the due instant reminders were last built against. */ + to: string; + reminders: ScheduledReminder[]; +}; + +type TrackedMap = Record; + +/** Module-level cache of the master toggle (mirrors scan-sound's pattern). */ +let isEnabled = true; +let lastReconcileAt = 0; + +/** + * All tracked-map mutations run through this promise chain, one at a time. + * Without it, a checkout's sync racing a foreground reconcile could + * interleave read-modify-write on the map and drop a record — orphaning + * scheduled notification ids we could then never cancel, which is exactly + * the "nags about returned gear" failure this feature exists to prevent. + * Only fast local work runs inside the queue; fetches and permission + * dialogs stay outside. + */ +let mutationQueue: Promise = Promise.resolve(); + +function enqueue(task: () => Promise): Promise { + const run = mutationQueue.then(task, task); + // Keep the chain alive even when a task rejects (tasks handle their own + // errors, but a stray rejection must not wedge the queue forever). + mutationQueue = run.catch(() => {}); + return run; +} + +// ── Preference ──────────────────────────────────────────────────────────── + +/** + * Load the master toggle from storage into the module cache. + * + * @returns The enabled state (default true on missing/error). + */ +export async function loadRemindersPreference(): Promise { + try { + const stored = await AsyncStorage.getItem(ENABLED_KEY); + isEnabled = stored !== "false"; + } catch { + isEnabled = true; + } + return isEnabled; +} + +/** + * Persist the master toggle and apply it immediately. + * + * Turning OFF cancels every scheduled notification but KEEPS the tracked + * map, so turning back ON can restore reminders for still-ongoing bookings + * via a forced reconcile (no re-checkout needed). + * + * @param enabled - The new toggle state. + * @returns Whether OS notification permission is currently granted — the + * Settings screen uses a false return on enable to point the user at the + * OS settings. The preference itself is persisted regardless. + */ +export async function setRemindersEnabled(enabled: boolean): Promise { + isEnabled = enabled; + try { + await AsyncStorage.setItem(ENABLED_KEY, enabled ? "true" : "false"); + } catch { + // Cache still holds the choice for this session; storage retries next set. + } + if (!enabled) { + await enqueue(() => cancelAllScheduled()); + return true; + } + // Interactive: the user just flipped the switch, so the OS dialog may show. + const granted = await ensurePermission(true); + void reconcileBookingReminders({ force: true }); + return granted; +} + +// ── OS plumbing ─────────────────────────────────────────────────────────── + +/** + * One-time presentation setup: how reminders render if one fires while the + * app is foregrounded (quiet banner, no sound — the app's audio culture is + * scan feedback only), plus the Android channel. Safe to call repeatedly. + */ +export function initNotificationPresentation(): void { + const Notifications = getNotifications(); + if (!Notifications) return; + try { + Notifications.setNotificationHandler({ + handleNotification: () => + Promise.resolve({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), + }); + if (Platform.OS === "android") { + void Notifications.setNotificationChannelAsync(ANDROID_CHANNEL_ID, { + name: "Booking reminders", + importance: Notifications.AndroidImportance.DEFAULT, + }); + } + } catch { + // Partial native availability — degrade to no-op. + } +} + +/** + * Check (and, when `interactive`, request) notification permission. + * + * The request only ever happens on a user-initiated action (first checkout, + * or flipping the Settings toggle) — never on app launch — per the spec's + * "ask in context" rule. Runs OUTSIDE the mutation queue: the OS dialog can + * stay open indefinitely and must not block cancels. + * + * @param interactive - Allow showing the OS permission dialog. + * @returns Whether permission is granted. + */ +async function ensurePermission(interactive: boolean): Promise { + const Notifications = getNotifications(); + if (!Notifications) return false; + try { + const current = await Notifications.getPermissionsAsync(); + if (current.granted) return true; + if ( + current.status === Notifications.PermissionStatus.UNDETERMINED && + interactive + ) { + const requested = await Notifications.requestPermissionsAsync(); + return requested.granted; + } + return false; + } catch { + return false; + } +} + +// ── Tracked-map storage ─────────────────────────────────────────────────── + +async function readTracked(): Promise { + try { + const raw = await AsyncStorage.getItem(TRACKED_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + // Defensive: a corrupt value must not brick the feature forever. + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as TrackedMap; + } + return {}; + } catch { + return {}; + } +} + +/** + * Persist the map. Returns success so callers that just SCHEDULED + * notifications can roll them back when the ids failed to persist — + * an unpersisted id is an orphan we could never cancel. + */ +async function writeTracked(map: TrackedMap): Promise { + try { + await AsyncStorage.setItem(TRACKED_KEY, JSON.stringify(map)); + return true; + } catch { + return false; + } +} + +// ── Scheduling primitives ───────────────────────────────────────────────── + +async function cancelScheduled(reminders: ScheduledReminder[]): Promise { + const Notifications = getNotifications(); + if (!Notifications) return; + for (const r of reminders) { + try { + await Notifications.cancelScheduledNotificationAsync(r.notificationId); + } catch { + // Already fired / already cancelled — fine either way. + } + } +} + +// ── Public API ──────────────────────────────────────────────────────────── + +/** + * Sync one booking's reminders against the server's current truth. + * + * The single write path: re-fetches the booking, cancels whatever was + * scheduled before, and schedules a fresh plan only when the booking is + * genuinely out (ONGOING/OVERDUE). Call it after any checkout success and + * from reconcile — idempotent either way. + * + * Failure semantics of the fetch: + * - 404/403 → authoritative "gone" → cancel + untrack (heals web deletes + * and lost workspace access). + * - any other failure (offline, timeout, 5xx) → leave state untouched and + * let a later reconcile heal it. + * + * @param bookingId - The booking to sync. + * @param orgId - Its workspace (api calls are org-scoped). + * @param opts.interactive - Allow the OS permission prompt (pass true only + * from a direct user action, e.g. right after a checkout). + */ +export async function syncBookingReminders( + bookingId: string, + orgId: string, + opts?: { interactive?: boolean } +): Promise { + // Slow half — fetch + (possibly interactive) permission — OUTSIDE the + // queue so cancels never wait behind network timeouts or an open dialog. + let fetched: Awaited>["data"] = null; + let authoritativelyGone = false; + try { + const { data, error, status } = await api.booking(bookingId, orgId); + if (data) { + fetched = data; + } else if (status === 404 || status === 403) { + authoritativelyGone = true; + } else if (error || !data) { + return; // transient failure: leave existing reminders in place + } + } catch (e) { + if (__DEV__) console.warn("[reminders] sync fetch failed:", e); + return; + } + + const isOut = + fetched != null && + (fetched.booking.status === "ONGOING" || + fetched.booking.status === "OVERDUE"); + const canSchedule = + isOut && isEnabled + ? await ensurePermission(opts?.interactive ?? false) + : false; + + // Fast half — cancel/schedule/persist — serialized on the queue. + return enqueue(async () => { + try { + const map = await readTracked(); + const previous = map[bookingId]; + if (previous) { + await cancelScheduled(previous.reminders); + delete map[bookingId]; + } + + if (authoritativelyGone || fetched == null || !isOut) { + await writeTracked(map); + return; + } + + const booking = fetched.booking; + const record: TrackedBooking = { + bookingId: booking.id, + orgId, + name: booking.name, + to: booking.to, + reminders: [], + }; + + // OVERDUE still reaches here — the plan returns nothing for a + // past-due instant, but a future-due OVERDUE flag (clock skew) is + // harmless to keep tracking. + const plan = computeReminderPlan( + { + id: booking.id, + name: booking.name, + to: booking.to, + assetCount: booking.assetCount, + orgId, + }, + new Date() + ); + + // Track even when we can't schedule (disabled / permission denied): + // the record is what lets a later enable or permission grant restore + // reminders through reconcile, without re-checking anything out. + const Notifications = getNotifications(); + if (plan.length > 0 && canSchedule && Notifications) { + for (const item of plan) { + try { + const notificationId = + await Notifications.scheduleNotificationAsync({ + content: { + title: item.title, + body: item.body, + data: item.data, + }, + trigger: { + type: Notifications.SchedulableTriggerInputTypes.DATE, + date: item.fireAt, + ...(Platform.OS === "android" + ? { channelId: ANDROID_CHANNEL_ID } + : {}), + }, + }); + record.reminders.push({ + notificationId, + type: item.type, + fireAt: item.fireAt.toISOString(), + }); + } catch (e) { + if (__DEV__) console.warn("[reminders] schedule failed:", e); + } + } + } + + map[bookingId] = record; + if (!(await writeTracked(map))) { + // Ids that never persisted are ids we could never cancel — roll the + // schedules back so storage failure can't create eternal reminders. + await cancelScheduled(record.reminders); + return; + } + await enforceScheduleCap(); + } catch (e) { + if (__DEV__) console.warn("[reminders] sync failed:", e); + } + }); +} + +/** + * Cancel a booking's reminders immediately and stop tracking it. + * + * Used when we KNOW the booking closed on this device (checked in fully, + * cancelled, archived, deleted) — no fetch needed, and unlike sync it also + * works for a booking that no longer exists on the server. + * + * @param bookingId - The booking whose reminders should disappear. + */ +export function cancelBookingReminders(bookingId: string): Promise { + return enqueue(async () => { + try { + const map = await readTracked(); + const record = map[bookingId]; + if (!record) return; + await cancelScheduled(record.reminders); + delete map[bookingId]; + await writeTracked(map); + } catch (e) { + if (__DEV__) console.warn("[reminders] cancel failed:", e); + } + }); +} + +/** + * Cancel everything and forget it — scheduled notifications AND the tracked + * map. For sign-out: after it, fetches would 401 forever, so tracked + * records could never heal and reminders would fire for bookings the next + * account has no business hearing about. + */ +export function clearAllBookingReminders(): Promise { + return enqueue(async () => { + try { + const map = await readTracked(); + for (const record of Object.values(map)) { + await cancelScheduled(record.reminders); + } + await writeTracked({}); + } catch (e) { + if (__DEV__) console.warn("[reminders] clear-all failed:", e); + } + }); +} + +/** + * Re-derive every tracked booking's reminders from the server — the + * self-healing pass that runs on app foreground. + * + * Debounced within a foreground session (unless forced); a genuine + * background → foreground transition resets the debounce via + * {@link markReconcileStale}. Each booking then goes through + * {@link syncBookingReminders}, which cancels reminders for anything + * returned/extended/cancelled elsewhere and reschedules anything whose due + * time moved. + * + * @param opts.force - Skip the debounce (used by launch + Settings toggle). + */ +export async function reconcileBookingReminders(opts?: { + force?: boolean; +}): Promise { + const now = Date.now(); + if (!opts?.force && now - lastReconcileAt < RECONCILE_MIN_INTERVAL_MS) return; + lastReconcileAt = now; + + try { + const map = await readTracked(); + for (const record of Object.values(map)) { + await syncBookingReminders(record.bookingId, record.orgId, { + interactive: false, + }); + } + } catch (e) { + if (__DEV__) console.warn("[reminders] reconcile failed:", e); + } +} + +/** + * Reset the reconcile debounce. Called when the app leaves the foreground, + * so the NEXT return to foreground always reconciles — the moment the spec + * cares about (a booking may have been returned on another device while + * this one was away) — while rapid in-session flaps stay debounced. + */ +export function markReconcileStale(): void { + lastReconcileAt = 0; +} + +// ── Internals ───────────────────────────────────────────────────────────── + +/** + * Cancel every scheduled notification but keep the tracked records, so the + * master toggle can be flipped back on and reconcile restores everything. + * Runs inside the queue (callers enqueue it). + */ +async function cancelAllScheduled(): Promise { + const map = await readTracked(); + for (const record of Object.values(map)) { + await cancelScheduled(record.reminders); + record.reminders = []; + } + await writeTracked(map); +} + +/** + * Keep our pending notifications under the iOS 64 cap (with headroom). + * + * Counts only FUTURE-dated reminders (a fired one is no longer pending on + * the OS side; counting it would evict genuinely pending ones) and prunes + * fired entries from the records while at it. Drops the FURTHEST-OUT first + * — the soonest-due are the ones a person most needs — and warns + * unconditionally (not just in dev) so a drop is never silent. Dropped + * reminders come back automatically on a later reconcile once capacity + * frees (each sync rebuilds a booking's full plan). Runs inside the queue. + */ +async function enforceScheduleCap(): Promise { + const map = await readTracked(); + const now = Date.now(); + const pending: { bookingId: string; reminder: ScheduledReminder }[] = []; + let prunedFired = false; + + for (const record of Object.values(map)) { + const stillPending = record.reminders.filter( + (r) => new Date(r.fireAt).getTime() > now + ); + if (stillPending.length !== record.reminders.length) { + record.reminders = stillPending; + prunedFired = true; + } + for (const reminder of stillPending) { + pending.push({ bookingId: record.bookingId, reminder }); + } + } + + if (pending.length <= MAX_SCHEDULED) { + if (prunedFired) await writeTracked(map); + return; + } + + pending.sort( + (a, b) => + new Date(a.reminder.fireAt).getTime() - + new Date(b.reminder.fireAt).getTime() + ); + const overflow = pending.slice(MAX_SCHEDULED); + // why: unconditional — the spec says capping is "never silent", and in + // production this warning is the only trace (it also lands in Sentry + // breadcrumbs, which capture console.warn). + console.warn( + `[reminders] over the ${MAX_SCHEDULED} pending cap — dropping ${overflow.length} furthest-out reminder(s)` + ); + const Notifications = getNotifications(); + for (const { bookingId, reminder } of overflow) { + if (Notifications) { + try { + await Notifications.cancelScheduledNotificationAsync( + reminder.notificationId + ); + } catch { + // Best effort — worst case iOS drops one itself. + } + } + const record = map[bookingId]; + if (record) { + record.reminders = record.reminders.filter( + (r) => r.notificationId !== reminder.notificationId + ); + } + } + await writeTracked(map); +} diff --git a/apps/companion/lib/reminders/use-booking-reminders.ts b/apps/companion/lib/reminders/use-booking-reminders.ts new file mode 100644 index 0000000000..4e15ac03f6 --- /dev/null +++ b/apps/companion/lib/reminders/use-booking-reminders.ts @@ -0,0 +1,176 @@ +/** + * App-level wiring for booking reminders — the single hook `_layout.tsx` + * calls to bring the feature to life. Mirrors the shape of + * {@link file://../quick-actions.ts}: a lib/ hook owning its listeners with + * proper cleanup. + * + * Responsibilities: + * - One-time init: foreground presentation + Android channel + load the + * master toggle, then a forced reconcile so a fresh launch heals any + * drift immediately. + * - Foreground reconcile: every genuine background → foreground transition + * re-derives reminders from the server (in-session flaps stay debounced; + * leaving the foreground marks the debounce stale). + * - Tap handling: a reminder tap deep-links to its booking — switching to + * the booking's workspace first when it differs from the active one — + * for both the warm-start listener and the cold-start "the tap launched + * the app" case. + * + * All runtime access to expo-notifications goes through the lazy guarded + * module so pre-notifications builds no-op instead of crashing. + * + * @see {@link file://./service.ts} the runtime this hook drives + * @see {@link file://./notifications-native.ts} the lazy native guard + */ +import { useCallback, useEffect } from "react"; +import { AppState } from "react-native"; +import type { NotificationResponse } from "expo-notifications"; + +import { pushIntoTab } from "@/lib/navigation"; +import { useOrg } from "@/lib/org-context"; + +import { getNotifications } from "./notifications-native"; +import { + initNotificationPresentation, + loadRemindersPreference, + markReconcileStale, + reconcileBookingReminders, +} from "./service"; + +/** Payload shape attached to every reminder (see plan.ts `data`). */ +type ReminderTapData = { bookingId?: string; orgId?: string }; + +/** + * The tap that cold-started the app can ALSO be delivered to the warm + * listener (platform-dependent). Remember handled response ids so one tap + * never navigates twice (a double push means two back-taps to escape). + */ +const handledResponseIds = new Set(); + +function alreadyHandled(response: NotificationResponse): boolean { + const id = response.notification.request.identifier; + if (handledResponseIds.has(id)) return true; + handledResponseIds.add(id); + return false; +} + +/** + * Extract the tap payload from a notification response, defensively — the + * payload crosses a native boundary, so treat it as untyped. + */ +function tapDataFromResponse( + response: NotificationResponse | null | undefined +): { bookingId: string; orgId: string | null } | null { + const data = response?.notification.request.content.data as + | ReminderTapData + | undefined; + if (typeof data?.bookingId !== "string") return null; + return { + bookingId: data.bookingId, + orgId: typeof data.orgId === "string" ? data.orgId : null, + }; +} + +/** + * Mount-once hook that initializes the reminders runtime, reconciles on + * foreground, and routes reminder taps to their booking. + */ +export function useBookingReminders(): void { + const { currentOrg, organizations, setCurrentOrg } = useOrg(); + + /** + * Open the tapped booking, switching workspaces first when the reminder + * belongs to a different one (the booking screen fetches org-scoped, so + * navigating without switching would fail in the wrong workspace). + * `pushIntoTab` anchors the bookings list beneath the detail so "back" + * works even when the tab was never mounted (repo navigation rule). + */ + const openBooking = useCallback( + (tap: { bookingId: string; orgId: string | null }) => { + if (tap.orgId && tap.orgId !== currentOrg?.id) { + const target = organizations.find((org) => org.id === tap.orgId); + // The user left that workspace — the booking is unreachable; a + // navigation would just error. Let the tap open the app and stop. + if (!target) return; + setCurrentOrg(target); + } + pushIntoTab("/(tabs)/bookings", `/(tabs)/bookings/${tap.bookingId}`); + }, + [currentOrg?.id, organizations, setCurrentOrg] + ); + + // One-time init + launch reconcile. + useEffect(() => { + initNotificationPresentation(); + void loadRemindersPreference().then(() => + reconcileBookingReminders({ force: true }) + ); + }, []); + + // Self-heal on every genuine return to the foreground. Marking the + // debounce stale on the way OUT means the next 'active' always + // reconciles, while rapid in-session flaps stay debounced. + useEffect(() => { + const sub = AppState.addEventListener("change", (next) => { + if (next === "active") { + void reconcileBookingReminders(); + } else if (next === "background" || next === "inactive") { + markReconcileStale(); + } + }); + return () => sub.remove(); + }, []); + + // Cold start: the app was launched by tapping a reminder. Mirrors the + // quick-actions pattern — small delay so navigation mounts settle first. + // Re-runs when org context changes, which is safe: `alreadyHandled` + // guarantees the launch notification is processed at most once. + useEffect(() => { + const Notifications = getNotifications(); + if (!Notifications) return; + let timer: ReturnType | undefined; + let cancelled = false; + (async () => { + try { + // why: getLastNotificationResponseAsync is marked deprecated in + // favour of the useLastNotificationResponse hook, but that hook + // touches the native module at render time — incompatible with the + // lazy guard that keeps pre-notifications builds alive. The async + // getter still works and stays behind the guard. + const last = await Notifications.getLastNotificationResponseAsync(); + if (last && alreadyHandled(last)) return; + const tap = tapDataFromResponse(last); + if (tap && !cancelled) { + timer = setTimeout(() => openBooking(tap), 300); + } + } catch { + // Partial native availability — no-op. + } + })(); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [openBooking]); + + // Warm start: a reminder tapped while the app is running/backgrounded. + // Re-registered when org context changes so the handler always sees the + // current workspace list (cheap: remove + add). + useEffect(() => { + const Notifications = getNotifications(); + if (!Notifications) return; + try { + const sub = Notifications.addNotificationResponseReceivedListener( + (response) => { + if (alreadyHandled(response)) return; + const tap = tapDataFromResponse(response); + if (tap) openBooking(tap); + } + ); + return () => sub.remove(); + } catch { + // Partial native availability — no listener, no cleanup needed. + return undefined; + } + }, [openBooking]); +} diff --git a/apps/companion/package.json b/apps/companion/package.json index 40b4ba24fa..a7f0e1f6a3 100644 --- a/apps/companion/package.json +++ b/apps/companion/package.json @@ -44,6 +44,7 @@ "expo-image-manipulator": "~14.0.8", "expo-image-picker": "~17.0.10", "expo-linking": "~8.0.11", + "expo-notifications": "~0.32.17", "expo-quick-actions": "^6.0.1", "expo-router": "~6.0.23", "expo-secure-store": "~15.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b57af7be27..4a57d5be96 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: expo-linking: specifier: ~8.0.11 version: 8.0.11(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + expo-notifications: + specifier: ~0.32.17 + version: 0.32.17(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) expo-quick-actions: specifier: ^6.0.1 version: 6.0.1(expo@54.0.33) @@ -1980,6 +1983,7 @@ packages: '@evilmartians/lefthook@2.1.6': resolution: {integrity: sha512-ysZbzryf74wlISmgm0PH/n1lJ0HD7AHmI6DoJgWO9qzIETQknhGdmKbkOi0MjLYtiOHWQl8Dr2qwg0ksDpNZjA==} + cpu: [x64, arm64, ia32] os: [darwin, linux, win32] hasBin: true @@ -2320,6 +2324,9 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + '@ide/backoff@1.0.0': + resolution: {integrity: sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -5698,6 +5705,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -5806,6 +5816,9 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + badgin@1.2.3: + resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -6877,6 +6890,11 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + expo-application@7.0.8: + resolution: {integrity: sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q==} + peerDependencies: + expo: '*' + expo-asset@12.0.12: resolution: {integrity: sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ==} peerDependencies: @@ -7011,6 +7029,13 @@ packages: react: '*' react-native: '*' + expo-notifications@0.32.17: + resolution: {integrity: sha512-lwwzn7tImuzTzn9PAglZlS2VfZEvsfFGJTK9Eb8I4cqkGh2DI23YJFJH+WPEIu4QhDvk5JeBjklenJ8IZbmA4A==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-quick-actions@6.0.1: resolution: {integrity: sha512-BzYvKoF1dJWe+1E2X2gMY0k4d4yp+FPINIH9/kc/25/0QtXsi72Y3VPN7iM3bmpyxXKHQ/78KR0OXHbJ5o+t1Q==} peerDependencies: @@ -7370,6 +7395,7 @@ packages: git-raw-commits@5.0.1: resolution: {integrity: sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==} engines: {node: '>=18'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -7673,6 +7699,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -7751,6 +7781,10 @@ packages: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + is-negative-zero@2.0.3: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} @@ -8677,6 +8711,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -10578,6 +10616,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -12750,6 +12791,8 @@ snapshots: '@iconify/types@2.0.0': {} + '@ide/backoff@1.0.0': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -16319,6 +16362,14 @@ snapshots: asap@2.0.6: {} + assert@2.1.0: + dependencies: + call-bind: 1.0.8 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.7 + util: 0.12.5 + assertion-error@2.0.1: {} ast-types-flow@0.0.8: {} @@ -16484,6 +16535,8 @@ snapshots: babel-plugin-jest-hoist: 29.6.3 babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + badgin@1.2.3: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -17817,6 +17870,10 @@ snapshots: expect-type@1.3.0: {} + expo-application@7.0.8(expo@54.0.33): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.13.2)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + expo-asset@12.0.12(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): dependencies: '@expo/image-utils': 0.8.12 @@ -17965,6 +18022,21 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0) + expo-notifications@0.32.17(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): + dependencies: + '@expo/image-utils': 0.8.12 + '@ide/backoff': 1.0.0 + abort-controller: 3.0.0 + assert: 2.1.0 + badgin: 1.2.3 + expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.13.2)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) + expo-application: 7.0.8(expo@54.0.33) + expo-constants: 18.0.13(expo@54.0.33)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0) + transitivePeerDependencies: + - supports-color + expo-quick-actions@6.0.1(expo@54.0.33): dependencies: '@expo/image-utils': 0.8.12 @@ -18707,6 +18779,11 @@ snapshots: ipaddr.js@1.9.1: {} + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -18785,6 +18862,11 @@ snapshots: is-map@2.0.3: {} + is-nan@1.3.2: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + is-negative-zero@2.0.3: {} is-node-process@1.2.0: {} @@ -19917,6 +19999,11 @@ snapshots: object-inspect@1.13.4: {} + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + object-keys@1.1.1: {} object.assign@4.1.7: @@ -22083,6 +22170,14 @@ snapshots: util-deprecate@1.0.2: {} + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.20 + utils-merge@1.0.1: {} uuid@3.4.0: {} From d16d841f71339f5523c017d427ecc4faca24d490 Mon Sep 17 00:00:00 2001 From: Carlos Virreira Date: Fri, 24 Jul 2026 17:36:17 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(companion):=20address=20reminder=20revi?= =?UTF-8?q?ew=20=E2=80=94=20stale-sync=20guard,=20auth=20wipe,=20tap=20tim?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generation guard: cancellations (check-in, sign-out) bump a per-booking / global counter synchronously; a sync captures both before its fetch and its queued apply aborts if either moved. Closes the race where a reconcile fetch reading ONGOING lands after a check-in's cancel and resurrects reminders for returned gear. isEnabled is also re-checked at apply time for the toggle-off flavour of the same race. - Clear reminders on EVERY authenticated -> signed-out transition (session expiry, revocation, refresh failure via onAuthStateChange), not only the explicit sign-out button — after any of them reconciles 401 forever and a later user of the device must not see the previous account's bookings. - Cold-start reminder taps: wait for the workspace list and mark the response handled only after navigation ran, so a tap arriving before OrgProvider finishes loading is retried (previously it was consumed and the booking never opened). Warm taps during loading are left unhandled for the cold-start path to pick up. - Drop an always-true negation flagged by code scanning. --- apps/companion/lib/auth-context.tsx | 13 +++++ apps/companion/lib/reminders/service.ts | 48 +++++++++++++++-- .../lib/reminders/use-booking-reminders.ts | 52 +++++++++++++------ 3 files changed, 95 insertions(+), 18 deletions(-) diff --git a/apps/companion/lib/auth-context.tsx b/apps/companion/lib/auth-context.tsx index 37c5992144..dd246bdb6e 100644 --- a/apps/companion/lib/auth-context.tsx +++ b/apps/companion/lib/auth-context.tsx @@ -4,6 +4,7 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; @@ -30,11 +31,19 @@ const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { const [session, setSession] = useState(null); const [isLoading, setIsLoading] = useState(true); + // Tracks whether we last saw an authenticated session, so ANY transition + // to signed-out (expiry, revocation, refresh failure — not only the + // explicit signOut button) clears the previous account's scheduled + // booking reminders. After such a transition every reconcile would 401 + // forever, and a later user of the device must not receive the previous + // account's booking names. + const hadSessionRef = useRef(false); useEffect(() => { supabase.auth .getSession() .then(({ data: { session } }) => { + hadSessionRef.current = session !== null; setSession(session); }) .catch((err) => { @@ -47,6 +56,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { const { data: { subscription }, } = supabase.auth.onAuthStateChange((_event, session) => { + if (hadSessionRef.current && session === null) { + void clearAllBookingReminders(); + } + hadSessionRef.current = session !== null; setSession(session); }); diff --git a/apps/companion/lib/reminders/service.ts b/apps/companion/lib/reminders/service.ts index 2f0b0334d9..ed39df853a 100644 --- a/apps/companion/lib/reminders/service.ts +++ b/apps/companion/lib/reminders/service.ts @@ -100,6 +100,24 @@ function enqueue(task: () => Promise): Promise { return run; } +/** + * Staleness guard for in-flight syncs. A sync's fetch runs OUTSIDE the + * queue, so its snapshot can be overtaken by a cancellation: fetch reads + * ONGOING, the user checks in (cancel runs), then the stale sync's apply + * would re-create the record and reschedule reminders for returned gear. + * Cancellations bump these SYNCHRONOUSLY at call time; a sync captures the + * values before its fetch and its apply aborts if either moved. + */ +const bookingGenerations = new Map(); +let globalGeneration = 0; + +function bumpBookingGeneration(bookingId: string): void { + bookingGenerations.set( + bookingId, + (bookingGenerations.get(bookingId) ?? 0) + 1 + ); +} + // ── Preference ──────────────────────────────────────────────────────────── /** @@ -278,6 +296,11 @@ export async function syncBookingReminders( orgId: string, opts?: { interactive?: boolean } ): Promise { + // Capture the staleness guard BEFORE the fetch: if a cancellation lands + // while this sync is in flight, the apply below must not resurrect it. + const capturedGeneration = bookingGenerations.get(bookingId) ?? 0; + const capturedGlobal = globalGeneration; + // Slow half — fetch + (possibly interactive) permission — OUTSIDE the // queue so cancels never wait behind network timeouts or an open dialog. let fetched: Awaited>["data"] = null; @@ -288,8 +311,10 @@ export async function syncBookingReminders( fetched = data; } else if (status === 404 || status === 403) { authoritativelyGone = true; - } else if (error || !data) { - return; // transient failure: leave existing reminders in place + } else { + // Transient failure (offline, timeout, 5xx, aborted): leave existing + // reminders in place for the next reconcile. + return; } } catch (e) { if (__DEV__) console.warn("[reminders] sync fetch failed:", e); @@ -308,6 +333,15 @@ export async function syncBookingReminders( // Fast half — cancel/schedule/persist — serialized on the queue. return enqueue(async () => { try { + // A cancellation (check-in, sign-out, …) overtook this sync while its + // fetch was in flight — the snapshot is stale; applying it would + // resurrect reminders for gear that just came back. Drop it. + if ( + (bookingGenerations.get(bookingId) ?? 0) !== capturedGeneration || + globalGeneration !== capturedGlobal + ) { + return; + } const map = await readTracked(); const previous = map[bookingId]; if (previous) { @@ -347,7 +381,9 @@ export async function syncBookingReminders( // the record is what lets a later enable or permission grant restore // reminders through reconcile, without re-checking anything out. const Notifications = getNotifications(); - if (plan.length > 0 && canSchedule && Notifications) { + // `isEnabled` re-checked at apply time: the toggle may have flipped + // off while this sync's fetch was in flight. + if (plan.length > 0 && canSchedule && isEnabled && Notifications) { for (const item of plan) { try { const notificationId = @@ -400,6 +436,9 @@ export async function syncBookingReminders( * @param bookingId - The booking whose reminders should disappear. */ export function cancelBookingReminders(bookingId: string): Promise { + // Synchronous bump so any sync already in flight for this booking aborts + // at apply time instead of resurrecting what we are about to cancel. + bumpBookingGeneration(bookingId); return enqueue(async () => { try { const map = await readTracked(); @@ -421,6 +460,9 @@ export function cancelBookingReminders(bookingId: string): Promise { * account has no business hearing about. */ export function clearAllBookingReminders(): Promise { + // Synchronous global bump: every in-flight sync — for any booking — must + // abort rather than re-track anything after a sign-out wipe. + globalGeneration += 1; return enqueue(async () => { try { const map = await readTracked(); diff --git a/apps/companion/lib/reminders/use-booking-reminders.ts b/apps/companion/lib/reminders/use-booking-reminders.ts index 4e15ac03f6..6e72596f12 100644 --- a/apps/companion/lib/reminders/use-booking-reminders.ts +++ b/apps/companion/lib/reminders/use-booking-reminders.ts @@ -44,14 +44,20 @@ type ReminderTapData = { bookingId?: string; orgId?: string }; * The tap that cold-started the app can ALSO be delivered to the warm * listener (platform-dependent). Remember handled response ids so one tap * never navigates twice (a double push means two back-taps to escape). + * + * A response is marked handled only AFTER navigation actually ran — marking + * on sight would eat a cold-start tap that arrived before the workspace + * list finished loading, and the retry (the effect re-running once orgs + * load) would then find it "already handled" and never open the booking. */ const handledResponseIds = new Set(); -function alreadyHandled(response: NotificationResponse): boolean { - const id = response.notification.request.identifier; - if (handledResponseIds.has(id)) return true; - handledResponseIds.add(id); - return false; +function wasHandled(response: NotificationResponse): boolean { + return handledResponseIds.has(response.notification.request.identifier); +} + +function markHandled(response: NotificationResponse): void { + handledResponseIds.add(response.notification.request.identifier); } /** @@ -76,7 +82,12 @@ function tapDataFromResponse( * foreground, and routes reminder taps to their booking. */ export function useBookingReminders(): void { - const { currentOrg, organizations, setCurrentOrg } = useOrg(); + const { + currentOrg, + organizations, + setCurrentOrg, + isLoading: orgLoading, + } = useOrg(); /** * Open the tapped booking, switching workspaces first when the reminder @@ -123,9 +134,11 @@ export function useBookingReminders(): void { // Cold start: the app was launched by tapping a reminder. Mirrors the // quick-actions pattern — small delay so navigation mounts settle first. - // Re-runs when org context changes, which is safe: `alreadyHandled` - // guarantees the launch notification is processed at most once. + // Waits for the workspace list (a cross-org tap needs it to switch) and + // marks the response handled only after navigation ran, so an early run + // never eats the tap: the effect re-runs once orgs load and retries. useEffect(() => { + if (orgLoading) return; const Notifications = getNotifications(); if (!Notifications) return; let timer: ReturnType | undefined; @@ -138,10 +151,13 @@ export function useBookingReminders(): void { // lazy guard that keeps pre-notifications builds alive. The async // getter still works and stays behind the guard. const last = await Notifications.getLastNotificationResponseAsync(); - if (last && alreadyHandled(last)) return; + if (!last || wasHandled(last)) return; const tap = tapDataFromResponse(last); if (tap && !cancelled) { - timer = setTimeout(() => openBooking(tap), 300); + timer = setTimeout(() => { + openBooking(tap); + markHandled(last); + }, 300); } } catch { // Partial native availability — no-op. @@ -151,20 +167,26 @@ export function useBookingReminders(): void { cancelled = true; if (timer) clearTimeout(timer); }; - }, [openBooking]); + }, [openBooking, orgLoading]); // Warm start: a reminder tapped while the app is running/backgrounded. // Re-registered when org context changes so the handler always sees the - // current workspace list (cheap: remove + add). + // current workspace list (cheap: remove + add). Taps arriving while the + // workspace list is still loading are left UNhandled — the cold-start + // effect re-reads the last response once loading finishes and picks + // them up. useEffect(() => { const Notifications = getNotifications(); if (!Notifications) return; try { const sub = Notifications.addNotificationResponseReceivedListener( (response) => { - if (alreadyHandled(response)) return; + if (orgLoading || wasHandled(response)) return; const tap = tapDataFromResponse(response); - if (tap) openBooking(tap); + if (tap) { + openBooking(tap); + markHandled(response); + } } ); return () => sub.remove(); @@ -172,5 +194,5 @@ export function useBookingReminders(): void { // Partial native availability — no listener, no cleanup needed. return undefined; } - }, [openBooking]); + }, [openBooking, orgLoading]); } From b351b130be9d96ea6cb2ff31ddaad46557b3f296 Mon Sep 17 00:00:00 2001 From: Carlos Virreira Date: Fri, 24 Jul 2026 17:44:51 +0200 Subject: [PATCH 3/3] fix(companion): auth listener before initial session read; drop unused var - Register onAuthStateChange BEFORE getSession() and never let the stale initial snapshot clobber a fresher auth event, so a sign-out/refresh landing during startup can't be overwritten and its reminder cleanup can't be missed (Supabase's INITIAL_SESSION is caught by the listener). - Remove the now-unused destructured error in the sync fetch. --- apps/companion/lib/auth-context.tsx | 31 +++++++++++++++++-------- apps/companion/lib/reminders/service.ts | 2 +- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/apps/companion/lib/auth-context.tsx b/apps/companion/lib/auth-context.tsx index dd246bdb6e..419fa33d5b 100644 --- a/apps/companion/lib/auth-context.tsx +++ b/apps/companion/lib/auth-context.tsx @@ -40,9 +40,30 @@ export function AuthProvider({ children }: { children: ReactNode }) { const hadSessionRef = useRef(false); useEffect(() => { + // Listener FIRST, then the initial read: registering after getSession() + // leaves a window where a sign-out/refresh event lands before the stale + // initial session resolves and overwrites it (and the signed-out + // transition — and its reminder cleanup — would be missed). Supabase + // also emits INITIAL_SESSION on subscribe, which this ordering catches. + let receivedAuthEvent = false; + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((_event, session) => { + receivedAuthEvent = true; + if (hadSessionRef.current && session === null) { + void clearAllBookingReminders(); + } + hadSessionRef.current = session !== null; + setSession(session); + setIsLoading(false); + }); + supabase.auth .getSession() .then(({ data: { session } }) => { + // An auth event is always fresher than this snapshot — never let a + // stale restored session clobber it. + if (receivedAuthEvent) return; hadSessionRef.current = session !== null; setSession(session); }) @@ -53,16 +74,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { setIsLoading(false); }); - const { - data: { subscription }, - } = supabase.auth.onAuthStateChange((_event, session) => { - if (hadSessionRef.current && session === null) { - void clearAllBookingReminders(); - } - hadSessionRef.current = session !== null; - setSession(session); - }); - return () => subscription.unsubscribe(); }, []); diff --git a/apps/companion/lib/reminders/service.ts b/apps/companion/lib/reminders/service.ts index ed39df853a..fadec5a20d 100644 --- a/apps/companion/lib/reminders/service.ts +++ b/apps/companion/lib/reminders/service.ts @@ -306,7 +306,7 @@ export async function syncBookingReminders( let fetched: Awaited>["data"] = null; let authoritativelyGone = false; try { - const { data, error, status } = await api.booking(bookingId, orgId); + const { data, status } = await api.booking(bookingId, orgId); if (data) { fetched = data; } else if (status === 404 || status === 403) {