# Gate Pass — iOS App Reference

> The iOS app is **not React Native** — it is the same React web app wrapped in a native **WKWebView** via **Capacitor 8**. The native layer handles push notifications, camera scanning, SQLite, and network detection; all UI logic is React running inside the WebView.

---

## Architecture Overview

```
iOS Native Layer (Swift)
  ├── AppDelegate.swift          # Firebase init + FCM token bridge
  ├── Info.plist                 # ATS config, push entitlements, camera usage
  ├── GoogleService-Info.plist   # Firebase project config
  └── Podfile                    # CocoaPods: Capacitor plugins + Firebase

Capacitor Bridge
  └── Passes native events → JavaScript via message channel

React WebView (JavaScript)
  ├── AppMobile.tsx              # Entry point
  ├── routes.mobile.tsx          # 5-screen router
  └── All React components/hooks/stores (shared with web)
```

---

## Native Dependencies (CocoaPods)

| Pod | Purpose |
|-----|---------|
| `Capacitor` | Core bridge (JS ↔ native) |
| `CapacitorCommunity/sqlite` | Local SQLite for offline cache |
| `CapacitorMLKitBarcodeScanning` | Native camera QR/barcode scanning |
| `CapacitorPushNotifications` | APNs registration + notification delivery |
| `CapacitorPreferences` | UserDefaults key-value bridge |
| `CapacitorNetwork` | Network connectivity events |
| `Firebase/Messaging` | FCM token exchange + push delivery |

Install: `cd apps/web/ios/App && pod install`

---

## Entry Points

| File | Purpose |
|------|---------|
| `web/src/AppMobile.tsx` | React entry — `QueryClient` (`retry:1, refetchOnWindowFocus:false`) + toast below safe-area |
| `web/src/routes.mobile.tsx` | Router with 5 authenticated screens + login |
| `web/src/components/layout/AppShellMobile.tsx` | Bottom tab nav, safe-area insets, auth guard, push hook mount point |

---

## Mobile Screens

| Route | Component | Purpose |
|-------|-----------|---------|
| `/login` | `LoginPageMobile` | Email input → POST `/auth/send-otp` |
| `/login/otp` | `OtpPageMobile` | OTP entry → POST `/auth/verify-otp` |
| `/approvals` | `ApprovalsPage` | List of AWAITING_APPROVAL visitors (filtered client-side from GET `/visitors`) |
| `/visitor/:id` | `VisitorDetailPage` | Visitor detail + Approve/Reject actions |
| `/scan` | `ScanPage` | Native QR scanner → POST `/visitor-scanner/checkin` |
| `/more` | `MorePage` | Profile + logout (with device unregister before logout) |

Routes `/dashboard` and `/visitors` redirect → `/approvals` so old web-side navigation still works.

---

## HTTP API Calls (iOS App)

### Authentication

| Method | Endpoint | Notes |
|--------|----------|-------|
| POST | `/auth/send-otp` | `{ email }` — triggers OTP email |
| POST | `/auth/verify-otp` | `{ email, otp }` → `{ role, token, owner\|admin\|approver }` |
| GET | `/auth/dev-peek-otp` | Dev only — auto-fill OTP |

### Approvals Screen

| Method | Endpoint | Notes |
|--------|----------|-------|
| GET | `/visitors` | Returns all visible visitors; filtered client-side to `status === 'AWAITING_APPROVAL'` sorted by `approvalRequestedAt DESC` |

### Visitor Detail

| Role | Method | Endpoint | Purpose |
|------|--------|----------|---------|
| APPROVER | GET | `/approver/visitors/:id` | Visitor + approval context |
| APPROVER | POST | `/approver/visitors/:id/approve` | Approve with optional note |
| APPROVER | POST | `/approver/visitors/:id/reject` | Reject with optional note |
| OWNER/ADMIN | GET | `/visitors/:id` | Visitor detail |
| OWNER/ADMIN | POST | `/visitors/:id/approve-scan` | Approve |
| OWNER/ADMIN | POST | `/visitors/:id/reject-scan` | Reject |

### Scan Screen (via `scannerApi` / `scannerToken`)

| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/visitor-scanner/stats` | Check-in count badges |
| GET | `/visitor-scanner/scan-history` | Recent scan log |
| POST | `/visitor-scanner/checkin` | `{ shortId, confirmApprovalRequest? }` |
| GET | `/visitor-scanner/lookup/:query` | Manual visitor search |
| POST | `/visitor-scanner/walk-in-arrived` | Direct walk-in (no QR) |

### Profile / More Screen

| Role | Method | Endpoint | Purpose |
|------|--------|----------|---------|
| OWNER | GET | `/auth/me` | Owner profile |
| ADMIN | GET | `/visitors/admin-me` | Admin profile + flags |
| APPROVER | GET | `/approver/me` | Approver profile |

### AppShellMobile (every screen)

| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/visitors/admin-me` | Current admin profile (OWNER/ADMIN) |
| GET | `/approver/me` | Current approver profile + `canScanCheckpoint` flag (controls Scan tab visibility) |

---

## Push Notification Flow

### Full End-to-End Flow

```
App Launch
  │
  ├─ 1. FirebaseApp.configure()  (AppDelegate.application didFinishLaunchingWithOptions)
  │
  ├─ 2. usePushNotifications hook mounts (AppShellMobile, after login)
  │
  ├─ 3. PushNotifications.checkPermissions() / requestPermissions()
  │         → iOS shows system permission dialog on first launch
  │
  ├─ 4. PushNotifications.register()  →  UIApplication.registerForRemoteNotifications()
  │
  ├─ 5. AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken(deviceToken)
  │         → Messaging.messaging().apnsToken = deviceToken
  │
  ├─ 6. Firebase SDK exchanges APNs token with FCM servers
  │
  ├─ 7. MessagingDelegate.didReceiveRegistrationToken(fcmToken)
  │         → UserDefaults.standard.set(fcmToken, forKey: "CapacitorStorage.fcm_token")
  │
  ├─ 8. usePushNotifications: polls Preferences.get({ key: 'fcm_token' }) every 1s (max 10s)
  │
  ├─ 9. POST /approver/devices/register  or  /visitors/devices/register
  │         → { token: fcmToken, platform: 'ios' }
  │         → DB: DeviceToken row upserted
  │
  └─ 10. Notification listeners active (foreground + tap)
```

### Why iOS Bypasses the `registration` Event

`@capacitor/push-notifications` emits a `registration` event from `didRegisterForRemoteNotificationsWithDeviceToken` — but this is the **raw APNs device token**, not the FCM registration token. Additionally, our custom AppDelegate implementation intercepts this callback before Capacitor's plugin can emit it.

**Solution:** On iOS, `usePushNotifications` does NOT use the `registration` event. Instead it:
1. Calls `PushNotifications.register()` (triggers APNs registration)
2. Polls `Preferences.get({ key: 'fcm_token' })` directly (reads from UserDefaults where `MessagingDelegate` saves the FCM token)
3. Registers with the API as soon as the FCM token is found

Android is unaffected — the `registration` event value IS the FCM token there and works correctly.

### Device Registration API

| Role | Endpoint | Token stored in |
|------|----------|----------------|
| OWNER / ADMIN | `POST /visitors/devices/register` | `DeviceToken.ownerId` or `DeviceToken.adminId` |
| APPROVER | `POST /approver/devices/register` | `DeviceToken.approverId` |

Body: `{ token: "<fcm_token>", platform: "ios" }`

The endpoint **upserts** on the unique `token` field, so re-registering on the same device just re-binds to the current logged-in user.

### Device Unregister (Logout)

`MorePage` calls `unregisterDevice()` **before** `logout()` so the auth token is still valid:

```
1. Preferences.get({ key: 'fcm_token' })  →  get current FCM token
2. POST /approver/devices/unregister  or  /visitors/devices/unregister  { token }
3. Preferences.remove({ key: 'fcm_token' })
4. logout()  →  clear auth token + navigate to /login
```

### Foreground Notifications

When a push arrives while the app is in the foreground, `pushNotificationReceived` fires:
- `visitor.awaiting` kind → show `MobileApprovalPopup` (inline approve/reject)
- `visitor.arrived` kind → show a toast

### Tap Notifications

When the user taps a notification from the system tray, `pushNotificationActionPerformed` fires → navigate to `/visitor/:visitorId`.

---

## Native Plugin Calls

### Push Notifications (`@capacitor/push-notifications`)

| Call | When |
|------|------|
| `PushNotifications.checkPermissions()` | AppShellMobile mount — check if already granted |
| `PushNotifications.requestPermissions()` | First launch — show iOS system dialog |
| `PushNotifications.register()` | After permission granted — triggers APNs registration |
| `addListener('registrationError', cb)` | Log APNs registration failure |
| `addListener('pushNotificationReceived', cb)` | Foreground notification → toast / popup |
| `addListener('pushNotificationActionPerformed', cb)` | Tap → navigate to visitor detail |

> Note: `addListener('registration', cb)` is NOT used on iOS (see explanation above). Android still uses it.

### Preferences (`@capacitor/preferences`)

| Call | Key | Purpose |
|------|-----|---------|
| `Preferences.get` | `fcm_token` | Read FCM token saved by AppDelegate |
| `Preferences.remove` | `fcm_token` | Clear on logout |

Capacitor Preferences reads from `UserDefaults` under the `"CapacitorStorage."` prefix. AppDelegate writes to `UserDefaults.standard.set(token, forKey: "CapacitorStorage.fcm_token")` so the JS side can read it.

### Barcode / QR Scanning (`@capacitor-mlkit/barcode-scanning`)

| Call | When |
|------|------|
| `BarcodeScanner.isSupported()` | ScanPage mount — detect MLKit availability |
| `BarcodeScanner.checkPermissions()` | Before scan — check camera permission |
| `BarcodeScanner.requestPermissions()` | Prompt for camera access |
| `BarcodeScanner.scan({ formats: [QrCode] })` | Launch native full-screen QR overlay |

Fallback on web: `navigator.mediaDevices.getUserMedia()` + `jsQR` canvas processing.

### Network (`@capacitor/network`)

| Call | When |
|------|------|
| `Network.getStatus()` | App launch + on reconnect |
| `Network.addListener('networkStatusChange', cb)` | Trigger outbox drain on reconnect; show/hide offline banner |

Fallback on web: `navigator.onLine`.

### SQLite (`@capacitor-community/sqlite`)

Used for offline cache. Tables:

**`outbox`** — queued mutations while offline:
`id · kind · method · url · body · created_at · attempts · last_error`

**`cache`** — reference data for offline rendering:
`key · value · updated_at`

| Call | Purpose |
|------|---------|
| `SQLiteConnection.createConnection(db)` | First launch — create DB |
| `SQLiteConnection.retrieveConnection(db)` | Reuse open connection |
| `dbConnection.open()` | Open handle |
| `dbConnection.execute(schema)` | Create tables |
| `dbConnection.query(sql, params)` | Read outbox / cache |
| `dbConnection.run(sql, params)` | Write outbox / cache |

### Core Capacitor

| Call | Purpose |
|------|---------|
| `Capacitor.isNativePlatform()` | Gate native-only code paths (returns `false` in browser) |
| `Capacitor.getPlatform()` | Returns `'ios'` / `'android'` / `'web'` |

---

## Offline Flow

```
User action while offline
  → Axios interceptor detects no network (via Network plugin)
  → enqueueOutbox() inserts row into SQLite outbox
  → UI shows optimistic update

Network reconnects
  → Network.addListener fires networkStatusChange
  → outboxDrain() reads all queued rows
  → Replays each mutation via Axios (same endpoint + body)
  → On 2xx: delete outbox row
  → On 4xx (permanent error): drop row, log error
  → On 5xx / timeout: increment attempts, retry on next drain
  → Background drain also runs every 30 seconds
```

---

## Local Storage (iOS App)

| Key | Storage | Value |
|-----|---------|-------|
| `token` | `localStorage` | JWT (owner/admin/approver) |
| `role` | `localStorage` | `OWNER \| ADMIN \| APPROVER \| PLATFORM_ADMIN` |
| `userName` | `localStorage` | Display name (shown immediately without API round-trip) |
| `userEmail` | `localStorage` | Email address |
| `scannerToken` | `localStorage` | Checkpoint JWT (separate from main auth) |
| `fcm_token` | `UserDefaults` (via Capacitor Preferences) | FCM registration token (set by AppDelegate's MessagingDelegate) |

---

## Socket.IO (Real-time, Mobile)

Same socket client as desktop. On login, the app joins:
- `owner:{ownerId}` — for OWNER/ADMIN role
- `approver:{approverId}` — for APPROVER role

**Events handled by mobile:**

| Event | Handler | Action |
|-------|---------|--------|
| `visitor.awaiting` | `useApproverSocket` | Refresh approval list + show `MobileApprovalPopup` |
| `visitor.decided` | `useApproverSocket` | Update status in list |
| `visitor.arrived` | `useOwnerSocket` | Refresh counts |
| `request.created` | `useOwnerSocket` | Badge increment |
| `notification.new` | `notifications.store` | Badge increment + prepend to list |
| `notification.read` | `notifications.store` | Mark read |
| `notification.deleted` | `notifications.store` | Remove from list |

---

## AppDelegate.swift

Key responsibilities:

```swift
// 1. Initialize Firebase on launch
FirebaseApp.configure()

// 2. Set AppDelegate as Firebase MessagingDelegate
Messaging.messaging().delegate = self

// 3. Forward APNs device token to Firebase (so Firebase can exchange it for FCM token)
func application(_:didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    Messaging.messaging().apnsToken = deviceToken
}

// 4. Save FCM token to UserDefaults so JS can read it via Capacitor Preferences
extension AppDelegate: MessagingDelegate {
    func messaging(_:didReceiveRegistrationToken fcmToken: String?) {
        guard let token = fcmToken else { return }
        UserDefaults.standard.set(token, forKey: "CapacitorStorage.fcm_token")
    }
}
```

---

## Build & Sync

```bash
# 1. Build React app in mobile mode (reads .env.mobile for API URL)
cd apps/web
npm run build:mobile

# 2. Sync built assets + plugins to Xcode project
npx cap sync ios

# 3. Open Xcode
npx cap open ios

# 4. Run on simulator or device from Xcode
```

**`.env.mobile`** sets `VITE_API_URL=https://api.gp.vcarrd.worksqr.com/api` so the WebView communicates with the production API.

---

## Xcode Project Setup Checklist

| Item | Location | Status |
|------|----------|--------|
| Push Notifications capability | Xcode → Signing & Capabilities | Must be added |
| Background Modes → Remote notifications | Xcode → Signing & Capabilities | Must be enabled |
| `GoogleService-Info.plist` | `ios/App/App/` | Must match Firebase project |
| `NSCameraUsageDescription` | `Info.plist` | Required for QR scanner |
| `NSAppTransportSecurity` | `Info.plist` | ATS exceptions for production domain |
| `pod install` | `ios/App/` | Run after adding Firebase/Messaging pod |

---

## Testing Push Notifications

From `apps/api/`:

```bash
# Send test push to all registered device tokens in DB
npm run test:push

# Send to a specific FCM token
npm run test:push -- --token <fcm_token>
```

The script verifies Firebase init, lists tokens from DB, sends a real FCM push, and reports per-token success/failure with actionable error descriptions.
