# Unified API V2

Frontend should use this service as the single base URL:

- `https://krow-api-v2-e3g6witsvq-uc.a.run.app`

The gateway keeps backend services separate internally, but frontend should treat it as one API.

## 1) Auth routes

Full auth behavior, including staff phone flow and refresh rules, is documented in [Authentication](./authentication.md).

### Client auth

- `POST /auth/client/sign-in`
- `POST /auth/client/sign-up`
- `POST /auth/client/sign-out`

### Staff auth

- `POST /auth/staff/phone/start`
- `POST /auth/staff/phone/verify`
- `POST /auth/staff/sign-out`

### Shared auth

- `GET /auth/session`
- `POST /auth/sign-out`

## 2) Client routes

### Client reads

- `GET /client/session`
- `GET /client/dashboard`
- `GET /client/reorders`
- `GET /client/billing/accounts`
- `GET /client/billing/invoices/pending`
- `GET /client/billing/invoices/history`
- `GET /client/billing/current-bill`
- `GET /client/billing/savings`
- `GET /client/billing/spend-breakdown`
- `GET /client/coverage`
- `GET /client/coverage/stats`
- `GET /client/coverage/core-team`
- `GET /client/coverage/incidents`
- `GET /client/coverage/blocked-staff`
- `GET /client/coverage/swap-requests`
- `GET /client/coverage/dispatch-teams`
- `GET /client/coverage/dispatch-candidates`
- `GET /client/hubs`
- `GET /client/cost-centers`
- `GET /client/vendors`
- `GET /client/vendors/:vendorId/roles`
- `GET /client/hubs/:hubId/managers`
- `GET /client/team-members`
- `GET /client/shifts/scheduled`
- `GET /client/orders/view` deprecated compatibility alias
- `GET /client/orders/:orderId/reorder-preview`
- `GET /client/reports/summary`
- `GET /client/reports/daily-ops`
- `GET /client/reports/spend`
- `GET /client/reports/coverage`
- `GET /client/reports/forecast`
- `GET /client/reports/performance`
- `GET /client/reports/no-show`

### Client writes

- `POST /client/devices/push-tokens`
- `DELETE /client/devices/push-tokens`
- `POST /client/orders/one-time`
- `POST /client/orders/recurring`
- `POST /client/orders/permanent`
- `POST /client/orders/:orderId/edit`
- `POST /client/orders/:orderId/cancel`
- `POST /client/shift-managers`
- `POST /client/hubs`
- `PUT /client/hubs/:hubId`
- `DELETE /client/hubs/:hubId`
- `POST /client/hubs/:hubId/assign-nfc`
- `POST /client/hubs/:hubId/managers`
- `POST /client/billing/invoices/:invoiceId/approve`
- `POST /client/billing/invoices/:invoiceId/dispute`
- `POST /client/coverage/reviews`
- `POST /client/coverage/late-workers/:assignmentId/cancel`
- `POST /client/coverage/swap-requests/:swapRequestId/resolve`
- `POST /client/coverage/swap-requests/:swapRequestId/cancel`
- `POST /client/coverage/dispatch-teams/memberships`
- `DELETE /client/coverage/dispatch-teams/memberships/:membershipId`

Timeline route naming:

- `GET /client/shifts/scheduled` is the canonical client timeline route
- it returns shift-level scheduled items, not order headers
- `GET /client/orders/view` still returns the same payload for compatibility, but now emits a deprecation header

Coverage-review request payload may also send:

```json
{
  "staffId": "uuid",
  "assignmentId": "uuid",
  "rating": 2,
  "feedback": "Worker left the shift early without approval",
  "markAsFavorite": false,
  "issueFlags": ["LEFT_EARLY"],
  "markAsBlocked": true
}
```

If `markAsFavorite` is `true`, backend adds that worker to the business favorites list. If `markAsFavorite` is `false`, backend removes them from that list. If `markAsBlocked` is `true`, backend adds that staff member to the business-level blocked list and future apply or assign attempts are rejected until a later review sends `markAsBlocked: false`.

`GET /client/coverage` response notes:

- each shift item includes `locationName` and `locationAddress`
- each assigned worker item includes `hasReview`

Swap-review routes:

- `GET /client/coverage/swap-requests?status=OPEN`
- `POST /client/coverage/swap-requests/:swapRequestId/resolve`
- `POST /client/coverage/swap-requests/:swapRequestId/cancel`

Resolve example:

```json
{
  "applicationId": "uuid",
  "note": "Dispatch selected the strongest replacement candidate"
}
```

Dispatch-team routes:

- `GET /client/coverage/dispatch-teams`
- `GET /client/coverage/dispatch-candidates?shiftId=uuid&roleId=uuid`
- `POST /client/coverage/dispatch-teams/memberships`
- `DELETE /client/coverage/dispatch-teams/memberships/:membershipId`

Dispatch-team membership example:

```json
{
  "staffId": "uuid",
  "hubId": "uuid",
  "teamType": "CORE",
  "notes": "Preferred lead barista for this location"
}
```

Dispatch priority order is:

1. `CORE`
2. `CERTIFIED_LOCATION`
3. `MARKETPLACE`

Shift-manager creation example:

```json
{
  "firstName": "Nora",
  "lastName": "Lead",
  "email": "nora.lead@example.com",
  "phone": "+15550001234",
  "hubId": "uuid"
}
```

The manager is created as an invited business membership. If `hubId` is present, backend also links the manager to that hub.

## 3) Staff routes

### Staff reads

- `GET /staff/session`
- `GET /staff/dashboard`
- `GET /staff/profile/stats`
- `GET /staff/profile-completion`
- `GET /staff/availability`
- `GET /staff/clock-in/shifts/today`
- `GET /staff/clock-in/status`
- `GET /staff/payments/summary`
- `GET /staff/payments/history`
- `GET /staff/payments/chart`
- `GET /staff/orders/available`
- `GET /staff/shifts/assigned`
- `GET /staff/shifts/open`
- `GET /staff/shifts/pending`
- `GET /staff/shifts/cancelled`
- `GET /staff/shifts/completed`
- `GET /staff/shifts/:shiftId`
- `GET /staff/profile/sections`
- `GET /staff/profile/personal-info`
- `GET /staff/profile/industries`
- `GET /staff/profile/skills`
- `GET /staff/profile/documents`
- `GET /staff/profile/attire`
- `GET /staff/profile/tax-forms`
- `GET /staff/profile/emergency-contacts`
- `GET /staff/profile/certificates`
- `GET /staff/profile/bank-accounts`
- `GET /staff/profile/benefits`
- `GET /staff/profile/benefits/history`
- `GET /staff/profile/time-card`
- `GET /staff/profile/privacy`
- `GET /staff/faqs`
- `GET /staff/faqs/search`

Example `GET /staff/clock-in/shifts/today` item:

```json
{
  "assignmentId": "uuid",
  "shiftId": "uuid",
  "title": "Assigned espresso shift",
  "clientName": "Google Mountain View Cafes",
  "hourlyRate": 23,
  "roleName": "Barista",
  "location": "Google MV Cafe Clock Point",
  "locationAddress": "1600 Amphitheatre Pkwy, Mountain View, CA",
  "latitude": 37.4221,
  "longitude": -122.0841,
  "startTime": "2026-03-17T13:48:23.482Z",
  "endTime": "2026-03-17T21:48:23.482Z",
  "clockInMode": "GEO_REQUIRED",
  "allowClockInOverride": true,
  "geofenceRadiusMeters": 120,
  "nfcTagId": "NFC-DEMO-ANA-001",
  "attendanceStatus": "NOT_CLOCKED_IN",
  "clockInAt": null
}
```

Example `GET /staff/profile/stats` response:

```json
{
  "staffId": "uuid",
  "totalShifts": 12,
  "averageRating": 4.8,
  "ratingCount": 7,
  "onTimeRate": 91.7,
  "noShowCount": 1,
  "cancellationCount": 0,
  "reliabilityScore": 92.3
}
```

Order booking route notes:

- `GET /staff/orders/available` is the canonical order-level marketplace feed for recurring and grouped work
- `GET /staff/shifts/open` remains available for shift-level opportunities and swap coverage
- `POST /staff/orders/:orderId/book` books the future shifts of an order atomically for one role
- the `roleId` returned by `GET /staff/orders/available` is the role catalog id for the order booking flow
- the `roleId` returned by `GET /staff/shifts/open` is still the concrete `shift_roles.id` for shift-level apply

### Staff writes

- `POST /staff/profile/setup`
- `POST /staff/devices/push-tokens`
- `DELETE /staff/devices/push-tokens`
- `POST /staff/clock-in`
- `POST /staff/clock-out`
- `POST /staff/location-streams`
- `PUT /staff/availability`
- `POST /staff/availability/quick-set`
- `POST /staff/orders/:orderId/book`
- `POST /staff/shifts/:shiftId/apply`
- `POST /staff/shifts/:shiftId/accept`
- `POST /staff/shifts/:shiftId/decline`
- `POST /staff/shifts/:shiftId/request-swap`
- `POST /staff/shifts/:shiftId/submit-for-approval`
- `PUT /staff/profile/personal-info`
- `PUT /staff/profile/experience`
- `PUT /staff/profile/locations`
- `POST /staff/profile/emergency-contacts`
- `PUT /staff/profile/emergency-contacts/:contactId`
- `PUT /staff/profile/tax-forms/:formType`
- `POST /staff/profile/tax-forms/:formType/submit`
- `POST /staff/profile/bank-accounts`
- `PUT /staff/profile/privacy`

## 4) Upload and verification routes

These are exposed as direct unified aliases even though they are backed by `core-api-v2`.

### Generic core aliases

- `POST /upload-file`
- `POST /create-signed-url`
- `POST /invoke-llm`
- `POST /rapid-orders/transcribe`
- `POST /rapid-orders/parse`
- `POST /rapid-orders/process`
- `POST /verifications`
- `GET /verifications/:verificationId`
- `POST /verifications/:verificationId/review`
- `POST /verifications/:verificationId/retry`

### Staff upload aliases

- `POST /staff/profile/photo`
- `POST /staff/profile/documents/:documentId/upload`
- `PUT /staff/profile/documents/:documentId/upload`
- `POST /staff/profile/attire/:documentId/upload`
- `PUT /staff/profile/attire/:documentId/upload`
- `POST /staff/profile/certificates`
- `DELETE /staff/profile/certificates/:certificateId`

## 5) Notes that matter for frontend

- `roleId` on `POST /staff/shifts/:shiftId/apply` is the concrete `shift_roles.id` for that shift, not the catalog role definition id.
- `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend.
- Document routes now return only document rows. They do not mix in attire items anymore.
- Tax-form data should come from `GET /staff/profile/tax-forms`, not `GET /staff/profile/documents`.
- Staff benefit activity should come from `GET /staff/profile/benefits/history`; the summary card should keep using `GET /staff/profile/benefits`.
- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL.
- The frontend upload contract for documents, attire, and certificates is:
  1. `POST /upload-file`
  2. `POST /create-signed-url`
  3. `POST /verifications`
  4. finalize with:
     - `PUT /staff/profile/documents/:documentId/upload`
     - `PUT /staff/profile/attire/:documentId/upload`
     - `POST /staff/profile/certificates`
- Finalization requires `verificationId`. Frontend may still send `fileUri` or `photoUrl`, but the backend treats the verification-linked file as the source of truth.
- `POST /rapid-orders/process` is the single-call route for "transcribe + parse".
- `POST /client/orders/:orderId/edit` builds a replacement order from future shifts only.
- `POST /client/orders/:orderId/cancel` cancels future shifts only on the mobile surface and leaves historical shifts intact.
- Verification upload and review routes are live and were validated through document, attire, and certificate flows. Do not rely on long-lived verification history durability until the dedicated persistence slice is landed in `core-api-v2`.
- Attendance policy is explicit. Reads now expose `clockInMode` and `allowClockInOverride`.
- `clockInMode` values are:
  - `NFC_REQUIRED`
  - `GEO_REQUIRED`
  - `EITHER`
- all source-of-truth timestamps are UTC ISO 8601 values. Frontend should convert them to local time for display.
- For `POST /staff/clock-in` and `POST /staff/clock-out`:
  - send `nfcTagId` when clocking with NFC
  - send `latitude`, `longitude`, and `accuracyMeters` when clocking with geolocation
  - send `proofNonce` and `proofTimestamp` for attendance-proof logging; these are most important on NFC paths
  - send `attestationProvider` and `attestationToken` only when the device has a real attestation result to forward
  - send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides
  - if the worker is already clocked in, backend returns `409` with code `ALREADY_CLOCKED_IN`
- `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in.
- `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides.
- `GET /client/coverage/blocked-staff` is the review feed for workers currently blocked by that business.
- `POST /client/coverage/late-workers/:assignmentId/cancel` is the client-side recovery action when lateness is confirmed by incident evidence or elapsed grace time.
- `GET /client/coverage/swap-requests` is the manager/ops review feed for swap requests, candidate applications, and status.
- `GET /client/coverage/dispatch-candidates` returns ranked candidates with the dispatch-team priority already applied.
- swap auto-cancellation is backend-driven. If a swap request expires without a replacement, backend cancels the original assignment, marks the swap request `AUTO_CANCELLED`, and alerts both the manager path and the original worker.
- Raw location stream payloads are stored in the private v2 bucket; SQL only stores the summary and incident index.
- Push delivery is backed by:
  - SQL token registry in `device_push_tokens`
  - durable queue in `notification_outbox`
  - per-attempt delivery records in `notification_deliveries`
  - private Cloud Run worker service `krow-notification-worker-v2`
  - Cloud Scheduler job `krow-notification-dispatch-v2`

### Push token request example

```json
{
  "provider": "FCM",
  "platform": "IOS",
  "pushToken": "expo-or-fcm-device-token",
  "deviceId": "iphone-15-pro-max",
  "appVersion": "2.0.0",
  "appBuild": "2000",
  "locale": "en-US",
  "timezone": "America/Los_Angeles"
}
```

Push-token delete requests may send `tokenId` or `pushToken` either:

- as JSON in the request body
- or as query params on the `DELETE` URL

Using query params is safer when the client stack or proxy is inconsistent about forwarding `DELETE` bodies.

### Clock-in request example

```json
{
  "shiftId": "uuid",
  "sourceType": "GEO",
  "deviceId": "iphone-15-pro",
  "latitude": 37.4221,
  "longitude": -122.0841,
  "accuracyMeters": 12,
  "proofNonce": "nonce-generated-on-device",
  "proofTimestamp": "2026-03-16T09:00:00.000Z",
  "overrideReason": "Parking garage entrance is outside the marked hub geofence",
  "capturedAt": "2026-03-16T09:00:00.000Z"
}
```

### Location-stream batch example

```json
{
  "shiftId": "uuid",
  "sourceType": "GEO",
  "deviceId": "iphone-15-pro",
  "points": [
    {
      "capturedAt": "2026-03-16T09:15:00.000Z",
      "latitude": 37.4221,
      "longitude": -122.0841,
      "accuracyMeters": 12
    },
    {
      "capturedAt": "2026-03-16T09:30:00.000Z",
      "latitude": 37.4301,
      "longitude": -122.0761,
      "accuracyMeters": 20
    }
  ],
  "metadata": {
    "source": "background-workmanager"
  }
}
```

### Coverage incidents response shape

```json
{
  "items": [
    {
      "incidentId": "uuid",
      "assignmentId": "uuid",
      "shiftId": "uuid",
      "staffName": "Ana Barista",
      "incidentType": "OUTSIDE_GEOFENCE",
      "severity": "CRITICAL",
      "status": "OPEN",
      "clockInMode": "GEO_REQUIRED",
      "overrideReason": null,
      "message": "Worker drifted outside hub geofence during active monitoring",
      "distanceToClockPointMeters": 910,
      "withinGeofence": false,
      "occurredAt": "2026-03-16T09:30:00.000Z"
    }
  ],
  "requestId": "uuid"
}
```

## 6) Why this shape

- frontend gets one host
- backend keeps reads, writes, and service helpers separated
- routing can change internally later without forcing frontend rewrites
