# SupplierDev — API Specification

> This document defines the data contract between the backend and all clients (Web, iOS).  
> Currently implemented via **direct Supabase client calls** (no custom REST layer).  
> Future: REST or tRPC endpoints may wrap these for mobile-specific needs.

---

## 1. Architecture

```
iOS App  ──►  Supabase REST/SDK  ──►  PostgreSQL (RLS enforced)
Web App  ──►  Supabase SSR SDK   ──►  PostgreSQL (RLS enforced)
```

**Auth**: Supabase JWT tokens. All requests include `Authorization: Bearer <jwt>`.  
**RLS**: Row-level security enforces `user_id = auth.uid()` on all tables.  
**Base URL**: `https://${SUPABASE_PROJECT_REF}.supabase.co` — operator-managed; concrete value held outside the repository.

---

## 2. Authentication

### Sign Up
```
POST /auth/v1/signup
Body: { email, password }
Response: { user, session }
```

### Sign In
```
POST /auth/v1/token?grant_type=password
Body: { email, password }
Response: { access_token, refresh_token, user }
```

### Sign Out
```
POST /auth/v1/logout
Headers: Authorization: Bearer <token>
```

### Password Reset
```
POST /auth/v1/recover
Body: { email }
```

### Update User (profile)
```
PUT /auth/v1/user
Headers: Authorization: Bearer <token>
Body: { data: { name, department, language, avatar_url } }
```

---

## 3. Resource Endpoints

All table operations use the Supabase PostgREST API:
`/rest/v1/{table}?{filters}`

Common headers:
```
Authorization: Bearer <jwt>
apikey: <anon_key>
Content-Type: application/json
Prefer: return=representation   (for inserts/updates that return data)
```

---

### 3.1 Visits

**Table**: `projects`  
**iOS model**: `Visit`

#### List visits (current user)
```
GET /rest/v1/projects?order=visit_date.desc&select=*
```
Response: `Visit[]`

#### Get single visit with stations + measurements
```
GET /rest/v1/projects?id=eq.{id}&select=*,process_steps(*,cycle_measurements(*))
```
Response: `Visit` with nested `stations[].measurements[]`

#### Create visit
```
POST /rest/v1/projects
Body:
{
  "supplier_name": "Acme GmbH",
  "plant_location": "München",
  "product_name": "Getriebegehäuse",
  "visit_date": "2026-03-30",
  "customer_takt_time_sec": 52.5,
  "planned_oee": 85,
  "target_cycle_time_sec": 44.6,
  "notes": null
}
```

#### Update visit
```
PATCH /rest/v1/projects?id=eq.{id}
Body: { <changed fields> }
```

#### Delete visit
```
DELETE /rest/v1/projects?id=eq.{id}
```

---

### 3.2 Stations

**Table**: `process_steps`  
**iOS model**: `Station`

#### List stations for a visit
```
GET /rest/v1/process_steps?visit_id=eq.{visitId}&order=sort_order.asc&select=*,cycle_measurements(*)
```

#### Create station
```
POST /rest/v1/process_steps
Body:
{
  "visit_id": "<uuid>",
  "step_number": 3,
  "station_name": "M0300",
  "planned_cycle_time_sec": 42.0,
  "is_manual": true,
  "operator_count": 1,
  "sort_order": 2
}
```

#### Update station
```
PATCH /rest/v1/process_steps?id=eq.{id}
Body: { "station_name": "M0350", "planned_cycle_time_sec": 40.0 }
```

#### Reorder stations (batch update sort_order)
```
PATCH /rest/v1/process_steps?id=eq.{id}
Body: { "sort_order": <new_index> }
```
Perform one request per affected station.

#### Delete station
```
DELETE /rest/v1/process_steps?id=eq.{id}
```

---

### 3.3 Measurements

**Table**: `cycle_measurements`  
**iOS model**: `Measurement`

#### List measurements for a station
```
GET /rest/v1/cycle_measurements?process_step_id=eq.{stationId}&order=cycle_number.asc
```

#### Create measurement
```
POST /rest/v1/cycle_measurements
Body:
{
  "process_step_id": "<uuid>",
  "cycle_number": 5,
  "cycle_time_sec": 47.23,
  "is_outlier": false,
  "notes": null
}
```

#### Update measurement (edit time / toggle outlier / add note)
```
PATCH /rest/v1/cycle_measurements?id=eq.{id}
Body: { "cycle_time_sec": 46.8, "is_outlier": false, "notes": "robot hesitation" }
```

#### Delete measurement
```
DELETE /rest/v1/cycle_measurements?id=eq.{id}
```
After deletion, renumber remaining cycles client-side via batch PATCH.

---

### 3.4 Shift Records

**Table**: `shift_outputs`  
**iOS model**: `ShiftRecord`

#### List shift records for a visit
```
GET /rest/v1/shift_outputs
  ?visit_id=eq.{visitId}
  &shift_date=eq.{date}
  &shift_type=eq.{type}
  &order=hour_start.asc
```

#### Upsert shift record
```
POST /rest/v1/shift_outputs
Headers: Prefer: resolution=merge-duplicates
Body:
{
  "visit_id": "<uuid>",
  "shift_date": "2026-03-30",
  "shift_type": "Frühschicht",
  "hour_start": 6,
  "target_output": 52,
  "actual_output": 48,
  "remarks": "Materialengpass Stunde 6"
}
```

---

### 3.5 Actions

**Table**: `workshop_actions`  
**iOS model**: `Action`

#### List actions for a visit
```
GET /rest/v1/workshop_actions
  ?visit_id=eq.{visitId}
  &order=action_number.asc
```

#### Create action
```
POST /rest/v1/workshop_actions
Body:
{
  "visit_id": "<uuid>",
  "action_number": 4,
  "area": "M0300 Montage",
  "description": "Schrauber-Programm optimieren",
  "effort": "low",
  "benefit": "high",
  "status": "open",
  "responsible": "Max Müller",
  "target_date": "2026-04-15",
  "time_saving_sec": 4.5,
  "cost_saving_eur": 12000
}
```

#### Update action status
```
PATCH /rest/v1/workshop_actions?id=eq.{id}
Body: { "status": "in_progress" }
```

#### Delete action
```
DELETE /rest/v1/workshop_actions?id=eq.{id}
```

---

### 3.6 QAF Documents

**Table**: `qaf_uploads`  
**iOS model**: `QAFDocument`

#### List documents for a visit
```
GET /rest/v1/qaf_uploads
  ?visit_id=eq.{visitId}
  &order=uploaded_at.desc
```

#### Upsert document
```
POST /rest/v1/qaf_uploads
Headers: Prefer: resolution=merge-duplicates
Body:
{
  "visit_id": "<uuid>",
  "version_type": "aktuell",
  "file_name": "QAF_Acme_v3.xlsx",
  "file_path": "qaf/user-id/visit-id/aktuell.xlsx",
  "parsed_data": {
    "cycle_time_sec": 45.2,
    "oee_percent": 78.5,
    "cost_per_unit": 12.40,
    "reject_rate_percent": 1.8
  }
}
```

### 3.7 QAF-Differences Engine (SupplierPulse)

**Tables**: `qaf_file`, `qaf_manufacturing_step`, `qaf_part`, `qaf_comparison`,
`qaf_step_match`, `qaf_manufacturing_diff`, `qaf_structure_change`,
`qaf_plausibility_issue`, `qaf_root_cause`, `qaf_audit_log` (+ baseline/config rows).
All carry `project_id` and are RLS-scoped to the owning project (single-tenant pilot).

**iOS model**: not yet modelled — web-only for the BMW pilot.

Unlike the per-visit `qaf_uploads` flow above, batch analysis is **not** a plain
PostgREST upsert: it runs through a Next.js Server Action because it composes the
engine (parse → group by part number → match → diff → persist) server-side. Reads of
the persisted results use direct PostgREST queries.

#### Analyze a batch (Server Action)
```
analyzeQafBatch(projectId: string, files: File[])
  → { ok: true, data: { comparisons, filesParsed, fileErrors[], groupsNeedingReview } }
  → { ok: false, error: string }
```
- Auth: requires a valid session; **project ownership is verified up front** (an
  unowned `projectId` returns `project not found or access denied` before any parsing).
- Input guards: `.xlsx` only, max 20 MB per file; rejected files surface in `fileErrors`.
- Every `qaf_*` write is error-checked — a failed child insert marks that comparison as
  errored (it is not silently counted as successful).

#### Read recent comparisons
```
GET /rest/v1/qaf_comparison?project_id=eq.{projectId}&order=created_at.desc
```

#### Multi-QAF variant-match overrides (KAR-947, Server Actions)
```
setVariantMatchOverride(comparisonId: string, input: {
  altVariantId: string
  neuVariantId: string | null
  decision: 'matched' | 'unmatched'
  note: string | null
}) → { ok: true, data: null } → { ok: false, error: string }

clearVariantMatchOverride(comparisonId: string, altVariantId: string)
  → { ok: true, data: null } → { ok: false, error: string }
```
- Auth: requires a valid session; the comparison's owning project is resolved and
  RLS-checked before any write.
- `decision: 'unmatched'` confirms "this ALT variant genuinely has no NEU
  counterpart" — `neuVariantId` is ignored (forced to `null`) in that case, never
  trusted from caller input once `decision` already says there is no counterpart.
- Rejects an ALT/NEU variant whose identity is ambiguous (identical dimensions to
  at least one other variant in its own container) — such a variant cannot be
  unambiguously targeted by an override.
- Both actions immediately recompute and persist the comparison result — the
  override list is never applied lazily at read time.

#### Variante↔Standard-QAF creation flow (KAR-948, Server Actions)
```
listMultiQafContainerFiles(projectId: string)
  → { ok: true, data: { id, fileName, createdAt }[] } → { ok: false, error: string }

listStandardQafFilesForProject(projectId: string)
  → { ok: true, data: { id, fileName, partNumber, createdAt }[] } → { ok: false, error: string }

getMultiQafVariantOptions(projectId: string, fileId: string)
  → { ok: true, data: { family, confidence, auxiliaryCount, variants: VariantDefinition[] } }
  → { ok: false, error: string }

createVariantVsStandardComparison(projectId: string, multiQafFileId: string,
  variantId: string, standardFileId: string)
  → { ok: true, data: { comparisonId: string } } → { ok: false, error: string }
```
- Auth: requires a valid session; every id (project/file/variant) is verified to
  belong to the same project before use.
- `listStandardQafFilesForProject` only returns files with a confirmed Standard-QAF
  template type — a file whose summary sheet failed to classify is never offered.
- `getMultiQafVariantOptions` loads and deserializes ONE container lazily, on
  selection — never eagerly for every Multi-QAF container in a project.
- `createVariantVsStandardComparison` rejects a non-active (`inactive`/`reserved`)
  `variantId` — only an active variant can be compared.

> **Follow-up:** the 8-sheet Excel export builder exists engine-side; its HTTP download
> route (`/api/qaf-differences`) is currently a placeholder and is tracked separately.

---

### 3.8 Wertstromanalyse (VSM)

**Table**: `value_stream_maps`  
**iOS model**: not yet modelled — web-only.

Unlike the PostgREST-direct resources above, Wertstrom mutations go through Next.js
route handlers (`app/api/wertstrom/`) so Zod validation + referential-integrity checks
run server-side before any write — see `lib/api/schemas.ts`.

#### List value stream maps
```
GET /api/wertstrom
```
Auth: `vsm.read`. Response: `{ id, title, description, project_id, created_by,
created_at, updated_at }[]`.

#### Create a value stream map
```
POST /api/wertstrom
Body: { title: string, description?: string | null, project_id?: string | null }
```
Auth: `vsm.write`. Always a top-level map — `scenario_kind` defaults to `'current'`
(DB default), `parent_value_stream_id` to `null`; neither is client-settable here.

#### Update a value stream map
```
PUT /api/wertstrom/{id}
Headers: If-Match: <updated_at>   (optimistic locking — 409 on a stale/mismatched value)
Body: { title?, description?, project_id?, nodes?: VsmNode[], connections?: VsmConnection[],
        layout?: { viewport?: { x, y, zoom }, shiftModel?: ShiftModelInput } }
```
Auth: `vsm.write`. When `nodes` is present, every `connection.fromNodeId`/`toNodeId` in
`connections` must resolve to a `node.id` in the SAME payload (400 otherwise). `layout`
merges — any other key already in the column is preserved. `viewport` is unconditionally
overwritten by whatever the request sends (including absent, i.e. cleared — the editor
always has a live value). `shiftModel` (Wertstrom P4, B2) is only overwritten when the
request actually includes it — an request that omits it leaves a previously-saved value
untouched (it is genuinely-optional data, unlike `viewport`).

#### Delete a value stream map
```
DELETE /api/wertstrom/{id}
```
Auth: `vsm.write`.

#### Create a future-state scenario (Wertstrom P2, KAR-878/KAR-986)
```
POST /api/wertstrom/{id}/duplicate
```
Auth: `vsm.write`. Feature-gated behind `wertstromScenarios` (`config/profiles/`,
review-fix F2) — `false` in every profile until P5 ships (CAPABILITY-MATRIX.md scopes
this create-action to P5; P2 only owns the underlying data model); returns 404 when
disabled. No body. Duplicates the source map's `nodes`/`connections`/`layout`/
`project_id` into a new row titled `<source title> (Soll)`, with `parent_value_stream_id`
set to the source's id and `scenario_kind: 'future'`. `is_demo` is always `false` on the
new row (review-fix F1) — **never** inherited from the source, regardless of the
source's own `is_demo`. The source row must itself have `scenario_kind: 'current'` (or
no `scenario_kind` at all, treated the same — a pre-P2 row); duplicating an
already-derived scenario returns 400 (review-fix F3 — a scenario may only be derived
from the Ist-Wurzel, never chained off another scenario). Node/connection ids are
preserved (not regenerated) — a future scenario-comparison feature can match a node
between scenario and source by id. Response: the new row (201). Never modifies the
source row.

**`VsmNode`/`VsmConnection` JSONB shape — Wertstrom P2 additions (all optional, absent =
prior semantics, see `lib/vsm-types.ts` for full doc comments):**
- `VsmConnection.kind?: 'materialFlow' | 'information'` (absent = `'materialFlow'`) +
  `frequency?: string`.
- `VsmNode.inventoryKind?: 'fifo' | 'supermarket' | 'push'` (absent = generic storage) +
  `inventoryMaxQuantity?: number` (FIFO lane capacity).
- `VsmNode.provenance?: 'planned' | 'measured' | 'calculated' | 'imported' | 'assumed'`
  (absent = unknown).
- `value_stream_maps.parent_value_stream_id: string | null`, `scenario_kind: 'current' |
  'future' | 'alternative'` (`NOT NULL DEFAULT 'current'`) — see `MIGRATIONS.md` §7r /
  `supabase-migration-wertstrom-scenarios.sql`.

**`VsmNode`/`layout` JSONB shape — Wertstrom P4 additions (all optional, absent = prior
semantics, see `lib/vsm-types.ts` for full doc comments):**
- `VsmNode.availabilityPct?: number` (0–100, Standard OEE field "am Prozess" — never
  written into `oee`, see doc comment) + `mtbfMin?: number` / `mttrMin?: number` (minutes,
  Expert-tier reliability inputs).
- `VsmNode.transportFrequency?: string` (free text, e.g. "3x täglich" — Standard field on
  `type: 'transport'` nodes).
- `layout.shiftModel?: { hoursPerShift: number, shiftsPerDay: number, breakMinPerShift:
  number, plannedDowntimeMinPerShift?: number }` — the simple Arbeitszeitmodell (§8.2),
  mirrors `lib/vsm-engine`'s `ShiftModelInput` exactly; see the PUT merge note above for
  its preserve-if-omitted behavior (differs from `viewport`).

---

## 4. Storage

**Bucket**: `avatars` — user profile images  
**Bucket**: `qaf-files` — QAF Excel uploads

### Upload file
```
POST /storage/v1/object/{bucket}/{path}
Headers: Authorization: Bearer <token>
Body: <binary file>
```

### Get public URL
```
GET /storage/v1/object/public/{bucket}/{path}
```

---

## 5. Response Schemas

### Visit
```json
{
  "id": "uuid",
  "user_id": "uuid",
  "supplier_name": "Acme GmbH",
  "plant_location": "München",
  "product_name": "Getriegegehäuse",
  "visit_date": "2026-03-30",
  "customer_takt_time_sec": 52.5,
  "planned_oee": 85.0,
  "target_cycle_time_sec": 44.6,
  "notes": null,
  "created_at": "2026-03-30T08:00:00Z",
  "updated_at": "2026-03-30T08:00:00Z"
}
```

### Station
```json
{
  "id": "uuid",
  "visit_id": "uuid",
  "step_number": 3,
  "station_name": "M0300",
  "description": null,
  "planned_cycle_time_sec": 42.0,
  "is_manual": true,
  "operator_count": 1,
  "sort_order": 2,
  "created_at": "2026-03-30T08:00:00Z"
}
```

### Measurement
```json
{
  "id": "uuid",
  "process_step_id": "uuid",
  "cycle_number": 5,
  "cycle_time_sec": 47.23,
  "is_outlier": false,
  "notes": null,
  "measured_at": "2026-03-30T09:15:00Z"
}
```

### ShiftRecord
```json
{
  "id": "uuid",
  "visit_id": "uuid",
  "shift_date": "2026-03-30",
  "shift_type": "Frühschicht",
  "hour_start": 6,
  "target_output": 52,
  "actual_output": 48,
  "remarks": "Materialengpass",
  "created_at": "2026-03-30T07:05:00Z"
}
```

### Action
```json
{
  "id": "uuid",
  "visit_id": "uuid",
  "action_number": 4,
  "area": "M0300 Montage",
  "description": "Schrauber-Programm optimieren",
  "effort": "low",
  "benefit": "high",
  "status": "open",
  "responsible": "Max Müller",
  "target_date": "2026-04-15",
  "time_saving_sec": 4.5,
  "cost_saving_eur": 12000.0,
  "created_at": "2026-03-30T10:00:00Z"
}
```

---

## 6. Error Handling

### Supabase Error Format
```json
{
  "code": "PGRST116",
  "details": "...",
  "hint": "...",
  "message": "..."
}
```

### Common Error Codes
| Code | Meaning | Client Action |
|------|---------|---------------|
| `PGRST116` | Row not found | Show "Not found" |
| `23505` | Unique constraint | Show field-level error |
| `42501` | RLS policy violation | Redirect to login |
| `JWT expired` | Token expired | Refresh token, retry |
| `Network error` | Offline | Show offline banner, queue action |

### iOS Error Handling
```swift
enum SupplierDevError: Error {
    case notFound
    case unauthorized
    case networkUnavailable
    case validationFailed(String)
    case serverError(String)
}
```

---

## 7. Naming Conventions

| Concept | DB (snake_case) | iOS (camelCase) | Web (camelCase) |
|---------|----------------|-----------------|-----------------|
| Supplier visit | `projects` | `Visit` | `Project` / `Visit` |
| Manufacturing station | `process_steps` | `Station` | `ProcessStep` |
| Time recording | `cycle_measurements` | `Measurement` | `CycleMeasurement` |
| Production tracking | `shift_outputs` | `ShiftRecord` | `ShiftOutput` |
| Improvement item | `workshop_actions` | `Action` | `WorkshopAction` |
| QAF file | `qaf_uploads` | `QAFDocument` | `QAFUpload` |
| Parent ID field | `project_id` | `visitId` | `projectId` |

> **Note**: The DB uses `projects`/`project_id` for historical reasons.  
> iOS and new web code should use the semantic name `Visit`/`visitId`.

---

## 8. Pagination

For large datasets, use range headers:
```
Range: 0-49          (first 50 rows)
Range-Unit: items
```

Response includes:
```
Content-Range: 0-49/243
```

Standard page size: **50 items** for lists, **200** for measurements.

---

## 9. Offline / Sync Strategy (iOS)

1. **Optimistic UI**: Apply changes locally (SwiftData) immediately
2. **Queue**: If network unavailable, store operation in `SyncQueue`
3. **Retry**: Sync on next network reconnect (background task)
4. **Conflict resolution**: Server wins on conflict (last-write-wins)

Priority for offline support: measurements (stopwatch can run without network).
