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(withassets.current_reservation_idandreservation_items.assigned_asset_idsalready in the read path —src/lib/office/inventory.ts). These are the actual table names in the database and the application code today; theunits → assets/areas → spacesrenames landed in migration0002_assets_spaces_settings, andreservationshas been the table name since the0001baseline. 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_logis 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
/officeon 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.
2. Recommended tech stack
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/VisionKitDataScannerViewController(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/URLSessionbackground 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+ImageIOdownscaling 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
| Concern | Native SwiftUI | React Native / Expo | PWA / wrapped web |
|---|---|---|---|
| QR / barcode scanning | Best (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 sync | Best (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 compression | Best (PhotosUI, ImageIO) | Good | Limited / inconsistent file APIs |
| Push (APNs) | Best (first-class) | Good (expo-notifications) | None worth shipping on iOS today |
| Code reuse with TS web stack | None | High (TS, shared types/validators) | Highest (literally the web app) |
| Team ramp if eng is TS-only | High (new language) | Low | Lowest |
| App Store distribution to a tiny team | Native, clean TestFlight | TestFlight via EAS | Sideload/Home-screen only; no TestFlight |
| Ongoing upgrade burden | Low (one OS, one SDK) | Medium (RN/Expo SDK churn + native deps) | Low |
| Long-term fit for a scanner-heavy field tool | Best | Good | Poor |
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'svision-camera+expo-sqlite+expo-task-managercover 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/authenticatedSupabase roles are denied on every rental table (RLSadmin_only, auto-applied by therls_auto_enabletrigger), 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_reportsrow.
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
401from 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,Authmodule) 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 RESTtoken?grant_type=refresh_tokenourselves — 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_links→asset 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
outboxtable of pending mutations.
Outbound mutation queue (the outbox)
Each user action that changes server state becomes one outbox row:
| column | purpose |
|---|---|
id (uuid) | idempotency key sent to the API as Idempotency-Key header |
kind | mark_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) |
status | pending / inflight / done / failed |
attempts, next_attempt_at | exponential backoff |
created_at, last_error | diagnostics + the sync-status screen |
Rules:
- Idempotency is the whole game. The server treats
Idempotency-Keyas a dedupe key: applying the samemark_picked_uptwice 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 thesync_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-assetmark_*calls are independent and can flush in any order — the server derives the reservation status itself (the last return flipsconfirmed → 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,
NWPathMonitorconnectivity-restored, aBGTaskSchedulerperiodic task, and a debounced in-app timer while the app is active. - Backoff: exponential with jitter on transient failures;
next_attempt_atgates re-pickup so a dead network doesn't hot-loop the battery. - Conflict/Failed are visible, not silent. Anything not
Doneshows 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 indocs/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; trustrest-api.mdfor 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 carryAuthorization: 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
409with a typed code so the device can move the outbox row toConflict.
| # | Need | Method + path (rough) | Request | Response | Auth |
|---|---|---|---|---|---|
| 1 | Exchange / 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 |
| 2 | List 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|week | query | { 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 |
| 3 | Fetch one reservation detail (fresh revalidate / deep link). | GET /reservations/{id} | — | one reservation object (shape as in #2) | Bearer |
| 4 | Resolve 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 |
| 5 | List assets for an item (manual fallback when the sticker is unscannable). | GET /items/{itemId}/assets?location=MDE | query | { assets: [{ id, serial, asset_tag, code, condition, current_reservation_id }] } | Bearer |
| 6 | Mark 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 |
| 7 | Mark 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 |
| 8 | Get 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 |
| 9 | Submit 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_scansrow whenscanned_codeis present (the scan log is append-only; this gives a physical-handling audit trail alongsideaudit_log). - #2's
candidate_assetsis 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 separateGET /reservations/{id}/assetsthe app prefetches on open. - Every mutation must write a purposeful
audit_logrow (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 theaudit_log.sourceCHECK 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
confirmedpastreturn_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/devicesstoring{ 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 toapi.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.
matchfor shared, git-stored signing certs/profiles (no "it builds on my Mac only"),gymto build,pilotto upload to TestFlight. Wire it to GitHub Actions (macOS runner) so a tagged commit ships a build. Keep it minimal in v1 — even a singlefastlane betalane 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.
| Phase | Scope | Rough effort |
|---|---|---|
| 0 — Foundations | Xcode 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 MVP | Reservations 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 queue | GRDB cache + outbox, idempotency keys, optimistic UI, background flusher (NWPathMonitor + BGTaskScheduler), conflict/failed surfacing, sync-status screen. | ~2 weeks |
| 3 — Condition reports + photos + push | Condition-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_assetscost. 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).