Docs.

iOS staff app — design & architecture

architecture/ios-staff-app.md

Written overnight 2026-06-02 by Claude, autonomously, as a review-in-the-morning design doc. No code was built: this environment has no Xcode/simulator, so the deliverable is documentation only. Companion reading: docs/architecture/office.md (the trust model this app inherits), docs/architecture/auth.md (magic-link auth), docs/architecture/data-model.md (reservations/assets schema), and docs/architecture/inventory-provenance.md (the project's prior art on idempotent, queue-backed sync — the iOS offline queue borrows its shape).

Naming note. This doc uses the live schema's names: reservations, reservation_items, assets (with assets.current_reservation_id and reservation_items.assigned_asset_ids already in the read path — src/lib/office/inventory.ts). These are the actual table names in the database and the application code today; the units → assets / areas → spaces renames landed in migration 0002_assets_spaces_settings, and reservations has been the table name since the 0001 baseline. The API contract (§8) is the seam regardless of doc vocabulary drift.


1. Overview, goals & non-goals

A staff-only native iOS app whose entire reason to exist is to make gear check-out and check-in against a reservation fast and reliable in a warehouse with flaky wifi. A staff member opens a reservation, scans the QR sticker on each physical asset, marks it picked-up at pickup and returned at return, optionally captures a condition report (photos + notes), and the device syncs that back to the system — even if it queued the work offline for minutes or hours first.

Goals

  • One job, done well: check-out / check-in. Open a reservation → scan asset → validate it belongs to the reservation → mark picked_up / returned.
  • Offline-first. The warehouse wifi is unreliable. Every read is cached and every mutation is enqueued locally and synced opportunistically. The staffer never waits on the network to do their job.
  • Scanner-first asset identification. The primary input is the camera scanning a qr.studio.chat/<code> sticker; manual selection is the fallback.
  • Correct attribution & auditability. Every mutation carries the staffer's identity (a real Supabase Auth user) so the server's audit_log is accurate.
  • Zero trust on the device. The app holds a short-lived user JWT, never the Supabase service-role key. All privileged DB work happens server-side.

Non-goals

  • No client-facing features. No browsing the catalog, no booking, no client accounts. (Clients never touch this app.)
  • No payments / deposits / refunds UI. Money lives in /office on the web.
  • No catalog or inventory editing. Inventory is sheet-canonical in Phase 0 (docs/architecture/inventory-provenance.md); the app reads assets, it does not curate them.
  • No reservation creation or pricing. The reservation already exists (made via the web office or intake flow) before staff touch it physically.
  • No Android / web parity in v1. This is a small internal team on iPhones (see §12). A second platform is a later decision, not a v1 constraint.

Recommendation: native Swift + SwiftUI, targeting the current iOS major and the one before it (iOS N / N-1).

Why SwiftUI for this app

  • Camera + QR scanning is the core interaction. Native gets AVFoundation / VisionKit DataScannerViewController (live multi-barcode scanning with a system-grade UI) for free, with the lowest-latency, most reliable scanner on the platform. For a tool whose hot loop is "point, scan, confirm, repeat 30 times," scanner quality is the product.
  • Offline-first wants a real local DB + background execution. SwiftData (or GRDB) plus BGTaskScheduler / URLSession background uploads give us a durable local store and OS-scheduled sync that survives the app being backgrounded mid-warehouse-walk. This is awkward in a PWA (Safari evicts storage, no real background sync on iOS) and only partially solved in RN.
  • Camera condition-report capture (multi-photo, on-device compression before upload) is a native strength; PhotosUI + ImageIO downscaling keeps uploads small on bad wifi.
  • Tiny internal audience. We don't need code-sharing leverage across web and mobile to justify a cross-platform runtime; we need a rock-solid single-platform tool. Native removes a whole layer (the JS bridge, the Expo/EAS build service, the RN upgrade treadmill) that buys us nothing here.
  • Longevity. The team will keep iPhones for years; SwiftUI is the long-term bet Apple is investing in, and a small focused app is exactly where SwiftUI's rough edges don't bite.

The honest trade-off

The rest of the stack is TypeScript/Next.js/Supabase. Choosing Swift means a second language and toolchain that current web eng may not own. That's the real cost. It's acceptable here because the app talks to the system only through a small REST contract (§8) — there's no shared business logic to duplicate, so the "TS everywhere" benefit is thin. If staffing reality is "the only available engineer is TS-only," the answer flips to Expo (see the switch criterion below).

Alternatives compared

ConcernNative SwiftUIReact Native / ExpoPWA / wrapped web
QR / barcode scanningBest (VisionKit DataScannerViewController, AVFoundation)Good (vision-camera + code-scanner; native modules)Weak on iOS (BarcodeDetector unsupported in Safari; needs a JS lib like zxing/html5-qrcode, slower, jankier)
True offline queue & background syncBest (SwiftData/GRDB + BGTaskScheduler + background URLSession)Good (SQLite + expo-task-manager; iOS background limits apply)Poor (Safari storage eviction, no reliable Background Sync on iOS)
Camera photo capture + on-device compressionBest (PhotosUI, ImageIO)GoodLimited / inconsistent file APIs
Push (APNs)Best (first-class)Good (expo-notifications)None worth shipping on iOS today
Code reuse with TS web stackNoneHigh (TS, shared types/validators)Highest (literally the web app)
Team ramp if eng is TS-onlyHigh (new language)LowLowest
App Store distribution to a tiny teamNative, clean TestFlightTestFlight via EASSideload/Home-screen only; no TestFlight
Ongoing upgrade burdenLow (one OS, one SDK)Medium (RN/Expo SDK churn + native deps)Low
Long-term fit for a scanner-heavy field toolBestGoodPoor

Decision & switch criterion

  • Default: native SwiftUI. Best scanner, best offline, best camera, lowest long-term maintenance for a focused field tool; the cross-platform code-reuse argument is weak because the integration surface is one REST contract.
  • Switch to Expo (React Native) if the team that will own this app is TypeScript-only and we accept slightly worse scanning/offline ergonomics in exchange for shared language, shared zod validators with /api/office, and a single hiring pool. Expo's vision-camera + expo-sqlite + expo-task-manager cover the requirements adequately.
  • Never the PWA route for the primary tool: iOS Safari's storage eviction and absent background sync make "offline-first in a warehouse" unreliable, and there's no TestFlight distribution. A read-only web view of a reservation is a fine fallback, not the product.

3. High-level architecture

The app never holds privileged credentials. It authenticates a person (Supabase Auth), gets a short-lived user JWT, and calls a dedicated REST API (/api/office/*, Next.js route handlers on Vercel). The server verifies the JWT + the account's office role, then does the privileged DB work with the service-role key, which lives only on the server — exactly the trust model already documented in docs/architecture/office.md.

flowchart LR
  subgraph Device["iPhone — staff device (untrusted)"]
    APP["SwiftUI app\n• Keychain: user JWT + refresh token\n• SwiftData/GRDB cache\n• outbound mutation queue"]
  end

  subgraph Edge["Supabase Auth (GoTrue)"]
    AUTH["Magic-link / email OTP\nissues + refreshes user JWT"]
  end

  subgraph Vercel["Next.js on Vercel — server (trusted)"]
    API["/api/office/* route handlers\n• verify JWT (Supabase JWKS)\n• resolve office role (minRole)\n• zod-validate body\n• idempotency keys"]
    SR(["SUPABASE_SERVICE_ROLE_KEY\nserver-only secret"])
    API --- SR
  end

  subgraph DB["Supabase Postgres"]
    PG[("reservations, reservation_items,\nassets, condition_reports,\nshort_links, short_link_scans,\naudit_log — all RLS-locked admin_only")]
    STORE[["Storage bucket:\ncondition-report photos"]]
  end

  APP -- "1. sign in (magic link / OTP)" --> AUTH
  AUTH -- "2. user JWT + refresh" --> APP
  APP -- "3. HTTPS + Bearer JWT\n   (idempotent mutations)" --> API
  API -- "4. service-role queries\n   (bypass RLS)" --> PG
  API -- "signed upload URL" --> STORE
  APP -. "5. direct photo PUT to signed URL" .-> STORE

  classDef trusted fill:#16351f,stroke:#3fa45b,color:#dff;
  classDef untrusted fill:#3a1f1f,stroke:#c25b5b,color:#fdd;
  class Vercel,DB,Edge trusted;
  class Device untrusted;

The trust boundary in one sentence: everything left of the Vercel box is untrusted and holds only a short-lived user token; the service-role key never leaves the server, so a stolen/jailbroken device can do nothing the signed-in staffer couldn't already do through /office.

Two things to call out:

  • The device talks only to /api/office/* for data, never to PostgREST directly. The anon/authenticated Supabase roles are denied on every rental table (RLS admin_only, auto-applied by the rls_auto_enable trigger), so a direct supabase-js call from the device would read nothing anyway. The REST API is the only door.
  • Photo bytes bypass the API for the upload itself. The API mints a short-lived signed upload URL (Supabase Storage) and the device PUTs the (compressed) image straight to storage. This keeps large multipart bodies off the Vercel function and works better on flaky wifi (resumable, retryable). The API only records the resulting object path on the condition_reports row.

4. Authentication flow

Same primitives as the web office: Supabase Auth magic-link / email OTP, office-role-gated (docs/architecture/auth.md). The only mobile-specific wrinkles are (a) prefer the 6-digit OTP code path over deep-linking the magic URL — typing a code is more robust on a shared/locked-down device and avoids universal-link plumbing — and (b) store the session in the Keychain.

sequenceDiagram
  participant U as Staffer
  participant App as iOS app
  participant KC as Keychain
  participant GT as Supabase Auth (GoTrue)
  participant API as /api/office/*
  participant JWKS as Supabase JWKS

  U->>App: enter work email, tap "Send code"
  App->>GT: signInWithOtp(email)
  Note over GT: GoTrue emails a 6-digit code\n(+ magic link). Sending an email is\nnot proof of access — server still gates.
  GT-->>U: email with OTP code
  U->>App: type 6-digit code
  App->>GT: verifyOtp(email, code)
  GT-->>App: access_token (JWT, ~1h) + refresh_token
  App->>KC: store {access, refresh} (kSecAttrAccessibleAfterFirstUnlock)

  Note over App,API: every data call from here on:
  App->>API: GET /reservations  (Authorization: Bearer <JWT>)
  API->>JWKS: verify signature (cached JWKS)
  API->>API: resolve account, check office role
  alt valid JWT AND has office role
    API-->>App: 200 + data
  else valid JWT, NO office role
    API-->>App: 403 (sign out + show "not authorized")
  else expired / invalid JWT
    API-->>App: 401 (trigger refresh, see below)
  end

Token refresh

  • The access token is short-lived (~1h). The app keeps the refresh token in Keychain and refreshes proactively (a little before expiry) and reactively (on a 401 from the API, refresh once then retry the original request exactly once).
  • Refresh happens against GoTrue, not the API. If refresh fails (refresh token revoked/expired), the app drops to the sign-in screen but keeps the local cache and the outbound queue intact — a re-auth must not lose queued check-ins. The queue resumes flushing after the new token lands.
  • The Supabase Swift SDK (supabase-swift, Auth module) manages refresh-token rotation; we let it, and only mirror the resulting session into Keychain ourselves if we want explicit control. (If we choose not to embed the Supabase SDK, we call GoTrue's REST token?grant_type=refresh_token ourselves — both are fine.)

Sign-out

  • Sign-out calls GoTrue signOut (revokes the refresh token), clears the Keychain session, and — importantly — warns if the outbound queue is non-empty ("3 changes haven't synced yet; sign out anyway?"). On a shared device this prevents one staffer's queued work being orphaned under another's next login. Default to flushing before sign-out when online.

Hardening notes

  • JWT stored with kSecAttrAccessibleAfterFirstUnlock (usable after first unlock for background sync, never in a backup, never before first unlock).
  • Optional Face ID / passcode gate on app foreground for shared devices (LAExceptionContext) — cheap, recommended if devices are shared (§12).
  • Certificate handling via standard ATS; no pinning in v1 (Vercel rotates certs; pinning would be an ops footgun for a tiny team).

5. Check-out & check-in flows

Both flows share the spine: open reservation → scan sticker → resolve code to asset → validate the asset is on this reservation → record the state change. The difference is the target state (picked_up vs returned) and that return optionally captures a condition report. Every mutation is enqueued locally first and may flush immediately (online) or later (offline) — see §6.

Validation rule (the load-bearing check)

A scanned asset is valid for this reservation iff its item_id matches one of the reservation's reservation_items and there is remaining capacity on that line (i.e. count(picked_up assets for the line) < line.qty). The asset is then bound to that line by appending its id to reservation_items.assigned_asset_ids (server-side) and setting assets.current_reservation_id. Scanning the wrong asset (not on any line, or a line already fully satisfied) is a hard, in-app error — no network round-trip needed to reject it, because the reservation detail (with its line items) is already cached on the device.

Check-out (pickup)

flowchart TD
  A["Open reservation\n(status: confirmed)"] --> B["Show per-asset checklist\n(lines + assigned/expected assets)"]
  B --> C{"Scan or select"}
  C -->|scan QR| D["Resolve qr.studio.chat/<code>\n→ asset (cache → API)"]
  C -->|manual| E["Pick asset from line's\ncandidate assets"]
  D --> F{"Asset on a line\nwith remaining qty?"}
  E --> F
  F -->|no| G["Reject: wrong/duplicate asset\n(local, instant)"]
  G --> C
  F -->|yes| H["Enqueue markPickedUp(asset)\nidempotency key = uuid"]
  H --> I["Optimistically mark line item ✓\n+ append short_link_scan"]
  I --> J{"More assets\non reservation?"}
  J -->|yes| C
  J -->|no, all picked| K["Done — reservation stays\nconfirmed (the first scan\nstamped picked_up_at)"]
  K --> L["Queue flushes when online."]

Server side, markPickedUp is idempotent on the asset+reservation pair: it requires the reservation to be confirmed, sets assets.current_reservation_id, appends to assigned_asset_ids, and stamps reservations.picked_up_at / actual_pickup_at on the first pickup. It never changes the reservation status — gear being out is scan state (picked_up_at + asset assignments inside the window), not a status (see src/lib/office/reservations.ts).

Check-in (return) with optional condition report

flowchart TD
  A["Open reservation\n(status: confirmed)"] --> B["Show checklist of\nout assets to recover"]
  B --> C{"Scan or select asset"}
  C --> D["Resolve → asset; confirm it's\nout on THIS reservation"]
  D --> E{"Capture condition report?"}
  E -->|no| H["Enqueue markReturned(asset)"]
  E -->|yes| F["Capture: photos (compressed)\n+ notes + condition enum"]
  F --> G["Enqueue uploadPhotos (signed URLs)\n+ submitConditionReport(direction=in)"]
  G --> H
  H --> I["Optimistically mark recovered ✓\n+ append short_link_scan"]
  I --> J{"All out assets recovered?"}
  J -->|no| C
  J -->|yes| K["Done — the server flips\nconfirmed → returned on the\nlast return (stamps returned_at\n+ actual_return_at)"]
  K --> L["Queue flushes when online."]

markReturned clears assets.current_reservation_id; partial returns leave the reservation confirmed (some gear still out — scan state tells which), and the last asset back transitions confirmed → returned server-side, stamping returned_at / actual_return_at (the web equivalent is the manual returned transition, which releases assets). Returns require confirmed, with returned tolerated so replayed scans stay idempotent. A condition report is an independent queued mutation tied to (reservation_id, asset_id, direction='in'|'out') so a failed photo upload never blocks the markReturned that records the physical fact.

Offline path (applies to both flows)

Nothing above blocks on the network. Each "Enqueue …" box writes a row to the local outbound queue with a client-generated idempotency key; the checklist updates optimistically from local state; a background flusher drains the queue when connectivity returns (§6). Resolving a scanned code uses the cached short_linksasset map first and only hits the API on a cache miss (rare — the reservation detail prefetch already pulls the candidate assets and their codes).


6. Offline-first sync design

The warehouse has flaky wifi, so the device is the temporary source of truth for the staffer's actions and the server is the permanent source of truth for everything else. We reconcile with server-authoritative conflict resolution + idempotency keys, deliberately reusing the philosophy already proven in docs/architecture/inventory-provenance.md (durable-event row written before work; idempotency key dedupes retries; a reconciliation pass heals drift).

Local store

Recommend GRDB (SQLite) over SwiftData for this app:

  • The workload is a durable write-ahead queue + relational reads (reservations, lines, assets). That's a SQLite sweet spot; GRDB gives explicit transactions, precise migrations, and battle-tested reliability.
  • SwiftData is tempting for the SwiftUI-native ergonomics, and is a fine second choice; but its migration story and queue-semantics are less mature than GRDB for "this row MUST flush exactly once." For a tool whose correctness hinges on the mutation queue, prefer the boring, explicit store.
  • Either way, the cache and the queue are two concerns in one DB: cached read models (reservations/lines/assets) and an outbox table of pending mutations.

Outbound mutation queue (the outbox)

Each user action that changes server state becomes one outbox row:

columnpurpose
id (uuid)idempotency key sent to the API as Idempotency-Key header
kindmark_picked_up / mark_returned / submit_condition_report
payload (json)the request body (asset id, reservation id, report fields, photo object paths)
depends_on (uuid?)ordering edge (e.g. condition-report depends on its photo uploads)
statuspending / inflight / done / failed
attempts, next_attempt_atexponential backoff
created_at, last_errordiagnostics + the sync-status screen

Rules:

  • Idempotency is the whole game. The server treats Idempotency-Key as a dedupe key: applying the same mark_picked_up twice is a no-op that returns the same result. So retries (after a flaky-wifi timeout where we don't know if the write landed) are always safe. This is the mobile analogue of the sync_events(sheet_version, row_range) dedupe in the inventory sync.
  • Server-authoritative on conflict, not last-write-wins. If the server says the asset is already returned, or the reservation was cancelled in the web office while the device was offline, the queued mutation is rejected with a typed error and surfaced on the sync-status screen for the staffer to reconcile — we do not silently clobber server state. LWW is wrong here because two humans (warehouse + front desk) can legitimately touch the same reservation; the server holds the invariants (state machine, line capacity), so it arbitrates.
  • Ordering only where it matters. Photo uploads must precede their condition report (depends_on); per-asset mark_* calls are independent and can flush in any order — the server derives the reservation status itself (the last return flips confirmed → returned), so there is no whole-reservation transition to enqueue.
  • Photos: each photo is uploaded to a signed URL first (its own retryable unit), and the condition-report mutation references the resulting object paths. Compress on-device (long edge ~2048px, JPEG ~0.6) before upload to survive bad wifi.

Reads / cache

  • On open and on a pull-to-refresh, fetch today/this-week reservations with their lines + candidate assets and store them as read models. This prefetch is what makes scan-validation work offline.
  • Reads are cache-first with background revalidation (stale-while-revalidate): show cached data instantly, refresh in the background, reconcile. Each cached reservation carries a fetched_at; the UI can show "as of HH:MM" so staff know how fresh it is.
  • Server-authored fields the device didn't write (e.g. another asset assigned via the web) overwrite the local read model on revalidate — the device never wins a read conflict.

Sync state machine

stateDiagram-v2
  [*] --> Pending: user action enqueued
  Pending --> Inflight: connectivity + token valid + deps done
  Inflight --> Done: 2xx (or 200 idempotent replay)
  Inflight --> Pending: network error / 401→refresh / 5xx\n(backoff, attempts++)
  Inflight --> Conflict: 409 (server-authoritative rejection)
  Inflight --> Failed: 4xx (bad request / 403)\nattempts ≥ max
  Conflict --> [*]: staffer reconciles (re-scan / discard)
  Failed --> Pending: manual retry from sync screen
  Done --> [*]
  • Trigger sources for the flusher: app foreground, NWPathMonitor connectivity-restored, a BGTaskScheduler periodic task, and a debounced in-app timer while the app is active.
  • Backoff: exponential with jitter on transient failures; next_attempt_at gates re-pickup so a dead network doesn't hot-loop the battery.
  • Conflict/Failed are visible, not silent. Anything not Done shows on the sync-status screen with a human-readable reason and a retry/discard action.

7. Screen inventory

Six screens, all in service of the one job. Wireframes are indicative.

1. Sign-in

   studio.chat — staff
   _____________________________
   [ work email                ]
   ( Send code )
   — after send —
   [ 6-digit code ]   ( Verify )
   note: only staff with an office role can sign in

2. Reservations list (today / this week) — segmented Today | This week; each row shows reference, client, pickup/return window, status, and a sync/offline indicator. Sorted by the next actionable time.

   [ Today | This week ]            ⟳ as of 09:14
   ─────────────────────────────────────────────
   R-2026-0602-03  Acme Films       09:00 pickup
   confirmed · 6 items                       ›
   ─────────────────────────────────────────────
   R-2026-0531-01  J. Pérez         18:00 return
   confirmed · 3 items out · 1 overdue       ›

3. Reservation detail — per-asset checklist — the core screen. Lines with expected qty, each expected/assigned asset as a checkable row (✓ when picked-up/returned), a prominent Scan button, and a footer state ("4 of 6 picked up").

   ⮐ R-2026-0602-03 · Acme Films · confirmed
   pickup 09:00 · return 06-05 18:00
   ─────────────────────────────────────────────
   Sony FX6  (qty 2)
     ☑ FX6 · SN 41207 · tag A-FX6-01   [MJQT]
     ☐ FX6 · SN 41208 · tag A-FX6-02   [PQ7K]
   Sigma 18-35  (qty 1)
     ☐ EF mount · tag A-S1835-02       [WX4N]
   ─────────────────────────────────────────────
   ( ▣ Scan asset )            4 of 6 picked up

4. Scanner — full-screen camera (VisionKit DataScannerViewController), live highlight on detected codes, haptic + sound on a valid scan, an inline red banner on an invalid/duplicate scan, and a "type code" fallback. Stays open for rapid sequential scanning; shows a running tally.

5. Condition-report capture (return, optional) — asset header, condition enum picker (like_new/good/fair/…), notes field, multi-photo grid with add/retake, and a per-photo upload state once queued.

6. Sync / queue status — list of outbox items with state (pending/inflight/done/conflict/failed), reason text on conflicts, and retry/discard actions; a global "N pending · last synced HH:MM" header. This is the staffer's window into offline state and the first place to look when something "didn't take."


8. Required API surface

Client requirements only. This section says what the app needs from /api/office/*. It is not the authoritative server contract — that now lives in docs/architecture/rest-api.md, where the read + pickup/return endpoints below are built and tested (auth, error codes, the per-asset check-out/check-in model). Items #8/#9 (condition-report photo upload) are deferred there pending a storage-bucket decision. Treat shapes here as the original request; trust rest-api.md for what shipped. Field names track the live schema (reservations, assets, reservation_items).

Shared conventions the app assumes

  • Base: https://studio.chat/api/office. All requests carry Authorization: Bearer <user JWT>; the server verifies the JWT and resolves the account's office role. Auth failures: 401 (expired/invalid → refresh & retry once), 403 (valid but no office role / insufficient role → sign out).
  • All mutations accept an Idempotency-Key: <uuid> header and are idempotent on it.
  • Errors are typed JSON: { error: { code, message } } with codes the app switches on (e.g. asset_not_on_reservation, line_capacity_full, reservation_wrong_state, already_applied).
  • Conflicts that the server arbitrates return 409 with a typed code so the device can move the outbox row to Conflict.
#NeedMethod + path (rough)RequestResponseAuth
1Exchange / validate session — confirm this JWT's account holds an office role; return display identity. (Auth itself is done against Supabase Auth; this just validates + resolves the role.)GET /me{ user: { id, email, roles, role } }Bearer; 403 if no office role
2List reservations (today / this week) with line items + assigned/candidate assets, so the device can validate scans offline. The working list is window pickups/returns plus anything live on the floor (status confirmed or returned) regardless of dates.GET /reservations?window=today|weekquery{ reservations: [{ id, reference, client:{display_name,preferred_language}, status, pickup_at, return_at, actual_pickup_at, actual_return_at, items:[{ id, item:{id,name,sku,serialized}, qty, assigned_asset_ids, candidate_assets:[{id,serial,asset_tag,code,condition}] }] }] }Bearer
3Fetch one reservation detail (fresh revalidate / deep link).GET /reservations/{id}one reservation object (shape as in #2)Bearer
4Resolve a scanned QR short-code → asset (cache-miss fallback).GET /assets/resolve?code={code}query{ asset: { id, item_id, item_name, serial, asset_tag, condition, current_reservation_id }, code }Bearer; 404 unknown code
5List assets for an item (manual fallback when the sticker is unscannable).GET /items/{itemId}/assets?location=MDEquery{ assets: [{ id, serial, asset_tag, code, condition, current_reservation_id }] }Bearer
6Mark asset picked-up on a reservation. Idempotent; requires status confirmed; binds asset→line, stamps picked_up_at/actual_pickup_at on the first pickup. Never changes the status.POST /reservations/{id}/pickups{ asset_id, scanned_code?, at }{ reservation_status, asset:{id,current_reservation_id}, line_id }Bearer + Idempotency-Key
7Mark asset returned. Idempotent; requires confirmed (returned tolerated for replays); releases asset; the LAST asset back flips confirmed → returned and stamps returned_at/actual_return_at.POST /reservations/{id}/returns{ asset_id, scanned_code?, at }{ reservation_status, asset:{id,current_reservation_id} }Bearer + Idempotency-Key
8Get a signed photo upload URL (so bytes bypass the API).POST /condition-reports/uploads{ reservation_id, asset_id, content_type, ext }{ upload_url, object_path, expires_at }Bearer
9Submit a condition report referencing already-uploaded photo paths.POST /condition-reports{ reservation_id, asset_id, direction:"in"|"out", condition, notes?, photo_object_paths:[...] }{ id }Bearer + Idempotency-Key

Notes for the server task:

  • #6/#7 should also append a short_link_scans row when scanned_code is present (the scan log is append-only; this gives a physical-handling audit trail alongside audit_log).
  • #2's candidate_assets is what lets the device validate scans and offer the manual fallback without a per-scan round-trip; if that's expensive server-side, an alternative is a separate GET /reservations/{id}/assets the app prefetches on open.
  • Every mutation must write a purposeful audit_log row (source='api', actor_user_id = the JWT's user) per the project's audit convention (docs/architecture/data-model.md). source='api' already exists in the audit_log.source CHECK constraint (docs/architecture/inventory-provenance.md).

9. Push notifications (APNs) — Phase 2

Short, and deferred. When it lands:

  • When staff get pinged: upcoming pickups (e.g. T-30 min for a same-day pickup), and overdue returns (reservation still confirmed past return_at). Maybe: a reservation cancelled in the web office while a device has it open.
  • What it needs: an APNs key in the Apple Developer account; a device-token registration endpoint (POST /api/office/devices storing { user_id, apns_token }); and a small server scheduler (a Vercel Cron at, say, 5-minute granularity, or reuse the existing cron pattern from the sync layer) that queries reservations for the two triggers and sends via APNs (token-based JWT auth to api.push.apple.com).
  • Why phase 2: the MVP value is the in-warehouse check-in/out loop; push is a convenience layer on top and adds Apple-key ops + a server scheduler. Not worth blocking v1.

10. Build, signing & distribution

For a tiny internal team:

  • Apple Developer Program membership is required ($99/yr) for TestFlight and APNs — this is the one hard prerequisite (see open questions).
  • Distribute via TestFlight. It's the cleanest fit: invite staff by email, push updates over the air, no per-device UDID juggling, supports internal (up to 100, no review) and external testers. Internal TestFlight is effectively our "app store" for a private team.
  • Apple Business Manager + MDM is the right answer if the company owns the devices and wants managed distribution / no Apple-ID-per-staffer. Recommended only if devices are company-owned and an MDM already exists; otherwise it's overkill for this team size. Ad Hoc (UDID-pinned .ipa) is a fallback if we want to avoid even TestFlight, but the per-device UDID maintenance makes it the worst option for anything but a one-off demo.
  • CI/signing: Fastlane. match for shared, git-stored signing certs/profiles (no "it builds on my Mac only"), gym to build, pilot to upload to TestFlight. Wire it to GitHub Actions (macOS runner) so a tagged commit ships a build. Keep it minimal in v1 — even a single fastlane beta lane run locally is enough until the cadence justifies CI.

11. Phased delivery plan

Effort estimates assume one engineer comfortable with SwiftUI; they're order-of-magnitude, not commitments.

PhaseScopeRough effort
0 — FoundationsXcode project, SwiftUI shell, Supabase Auth (OTP) + Keychain session, /me validation, networking layer w/ bearer + refresh, app icon, TestFlight pipeline (Fastlane).~1 week
1 — Online check-in/out MVPReservations list (today/week), reservation detail + per-asset checklist, VisionKit scanner, resolve code→asset, validate-against-lines, mark_picked_up / mark_returned (the server derives reservation status). Online-only (assume network).~2 weeks
2 — Offline queueGRDB cache + outbox, idempotency keys, optimistic UI, background flusher (NWPathMonitor + BGTaskScheduler), conflict/failed surfacing, sync-status screen.~2 weeks
3 — Condition reports + photos + pushCondition-report capture UI, on-device compression, signed-URL upload, submit_condition_report, APNs registration + upcoming-pickup / overdue-return notifications.~2–3 weeks
gantt
  title iOS staff app — phased delivery (indicative)
  dateFormat  YYYY-MM-DD
  axisFormat  %m-%d
  section Phase 0
  Foundations (auth, shell, CI)        :p0, 2026-06-08, 7d
  section Phase 1
  Online check-in/out MVP              :p1, after p0, 14d
  section Phase 2
  Offline queue + sync status          :p2, after p1, 14d
  section Phase 3
  Condition reports + photos + push    :p3, after p2, 18d

Ship Phase 1 to TestFlight and let staff use it online before building the offline queue — real usage will tell us which flows actually need offline hardening first, and an online-only tool is already useful in the office and anywhere with decent signal.


12. Open questions / decisions for the owner

  • Apple Developer account. Is there an active Apple Developer Program membership (or willingness to enroll)? It's the one hard blocker for TestFlight and APNs. Under whose entity — the Colombia S.A.S. or a personal Apple ID?
  • Device ownership & MDM. Are the iPhones company-owned? If so, do we want Apple Business Manager + MDM (managed distribution, no per-staffer Apple ID), or is TestFlight-by-email enough? This decides §10.
  • Shared vs. personal devices. Will staff share a warehouse iPhone or each use their own? Shared devices argue for a Face ID/passcode foreground gate and a stricter "flush before sign-out" policy (§4), and affect how we attribute audit_log.
  • Single location first? Start MDE-only (the public/rentable location), or do LAS staff need it too? Affects whether the reservations list and asset resolution filter by assets.location.
  • Native vs. Expo — who owns it? Confirm the §2 default (native SwiftUI) versus the switch criterion (Expo if the owning engineer is TS-only). This is the single biggest fork in the plan.
  • OTP code vs. magic-link deep link. Confirm we prefer typing the 6-digit OTP (recommended, simpler, robust on locked-down devices) over universal-link magic URLs.
  • Condition-report scope in v1. Is a return-time condition report needed in the first useful release, or can it wait for Phase 3? (It's the heaviest remaining piece — photos, storage, upload.)
  • candidate_assets cost. Is embedding candidate assets in the reservations list (#2) acceptable, or should the app prefetch them via a separate endpoint? Drives the offline-scan-validation design.
  • Localization. Staff UI in EN, ES, or both? The client-facing site is bilingual; staff may prefer ES in Medellín. Cheap to do from the start with String(localized:); expensive to retrofit.

Status: design only — not yet built (no iOS toolchain in this environment).