From 6e6b8baaeaa09a60289e9b9ba1395d00a111ec73 Mon Sep 17 00:00:00 2001 From: Tanyalouise Date: Fri, 14 Aug 2026 10:49:18 +0100 Subject: [PATCH 1/8] =?UTF-8?q?docs:=20backend=20requests=20=E2=80=94=20mi?= =?UTF-8?q?lestones=20read=20path=20+=20phase=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/backend-requests.md | 92 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/backend-requests.md diff --git a/docs/backend-requests.md b/docs/backend-requests.md new file mode 100644 index 0000000..3144be6 --- /dev/null +++ b/docs/backend-requests.md @@ -0,0 +1,92 @@ +# Backend requests + +Changes the frontend needs from the Clover CMS API. Written against the live +spec **v3.0.0** (`https://api.cloverdesign.xyz/docs.json`). Each is additive and +low-risk; the `Milestone` / `ProjectUpdate` data models already exist. + +--- + +## 1. Expose milestones for reading (blocking the milestone timeline) + +Today milestones are **write-only**: `POST` / `PUT` / `DELETE` exist under +`/api/projects/{id}/milestones`, but **no endpoint returns them** — they aren't +in any GET response and aren't embedded in the project. So a milestone is only +visible in the immediate response of the call that created/updated it; it can't +be re-read on reload or shown in the client portal. + +### 1a. Admin — new endpoint: `GET /api/projects/{id}/milestones` +- **Auth:** `AdminBearer` (approved admin), same as the milestone write endpoints. +- **Returns:** the project's milestones, **sorted by `order` ascending**. +- **Response** (standard envelope, `data` is a `Milestone[]`): + +```json +{ + "success": true, + "message": "Milestones retrieved", + "data": [ + { + "id": "…", + "projectId": "…", + "title": "Design handoff", + "description": null, + "status": "PENDING", + "order": 0, + "dueDate": "2026-08-14T00:00:00.000Z", + "completedAt": null, + "phase": "Design", + "createdAt": "…", + "updatedAt": "…" + } + ] +} +``` +- **Errors:** `401` (no/invalid token), `404` (project not found). + +### 1b. Portal — embed `milestones` in the project response +- **Endpoint:** `GET /api/portal/projects/{id}` (and `GET /api/portal/projects` + list items if cheap). +- **Auth:** `ClientBearer`, scoped to the client's own project (unchanged). +- **Change:** add a **`milestones: Milestone[]`** array to the returned `Project` + `data`, sorted by `order`. The portal has no separate milestones endpoint and + shouldn't need one — embedding keeps the client timeline to a single request. + +--- + +## 2. Add a `phase` field to `Milestone` (to group milestones by phase) + +Milestones and phases are currently unrelated: `Milestone` has no `phase`, and +`Project.phase` is a single string (the project's current stage). To show +milestones grouped under phases (Discovery → Design → Development → Launch), add +a phase to the milestone. Free-text string, mirroring `Project.phase` (nullable) +— no new enum/validation needed. + +- **Model:** add nullable `phase: string` to `Milestone` (+ migration). +- **Write bodies:** accept optional `phase` on + `POST /api/projects/{id}/milestones` and `PUT …/{milestoneId}`. +- **Response:** include `phase` in the `Milestone` returned everywhere (create, + update, and the read path in §1). + +Once §1 + §2 land, the admin milestone editor and a portal milestone timeline +both become real, grouped by phase, with per-phase progress possible. + +--- + +## Related gaps (lower priority, same class) + +- **Project `updates`** are also write-only (`POST` / `DELETE`, no GET). If + milestones get a read path, do the same for updates (embed in the portal + project response and/or `GET /api/projects/{id}/updates`) so the client + "project updates" feed can work. +- **Portal invoices** — the PRD shows invoices in the portal, but there's no + `/api/portal/.../invoices` endpoint. A client-scoped read would light up an + invoices section on the portal. + +--- + +## Notes + +- Frontend is already staged for §1: `Project.milestones?: Milestone[]` exists in + the client models and the admin editor reads it — a read path makes it durable + instead of session-only. +- Date fields are `date-time`; the frontend sends full ISO timestamps (a bare + `yyyy-mm-dd` is rejected as an invalid datetime). From 2f351d3fd5145bfb6774dd59f0d586d8202aaddf Mon Sep 17 00:00:00 2001 From: Tanyalouise Date: Fri, 14 Aug 2026 12:30:23 +0100 Subject: [PATCH 2/8] docs(backend): request notifications feed endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add §3 — GET /api/notifications contract for the new admin header bell: the Notification model, the four derived types (overdue invoices, pending revisions, deliverables awaiting review, milestones due) and their href targets. Read state is client-side, so no mark-read endpoint is requested. --- docs/backend-requests.md | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/docs/backend-requests.md b/docs/backend-requests.md index 3144be6..e636a8e 100644 --- a/docs/backend-requests.md +++ b/docs/backend-requests.md @@ -71,6 +71,69 @@ both become real, grouped by phase, with per-phase progress possible. --- +## 3. Notifications feed (header bell + attention badges) + +The admin shell now has a notifications bell and needs a server-generated +attention feed. There is **no notification model or endpoint today**. The +frontend is already built against the contract below and degrades gracefully +(empty "all caught up" state, no errors) until it ships. + +### 3a. New endpoint: `GET /api/notifications` +- **Auth:** `AdminBearer` (approved admin). Scoped to the calling admin. +- **Returns:** the admin's notifications, **newest first** (`createdAt` desc). + A reasonable cap (e.g. the most recent 50) is fine — the UI is a dropdown, + not an archive. +- **Polling:** the client refetches every ~60s; no websocket/SSE required. +- **Response** (standard envelope, `data` is a `Notification[]`): + +```json +{ + "success": true, + "message": "Notifications retrieved", + "data": [ + { + "id": "…", + "type": "INVOICE_OVERDUE", + "title": "Invoice overdue", + "body": "Acme Co · $4,200 · 3 days late", + "href": "/admin/invoices/inv_123", + "entityType": "invoice", + "entityId": "inv_123", + "createdAt": "2026-08-14T09:00:00.000Z" + } + ] +} +``` +- **Errors:** `401` (no/invalid token). + +### 3b. Notification model +Server **derives** these from existing domain signals (no admin authoring). The +four `type`s the frontend renders, and the signal each is generated from: + +| `type` | Generated when | `href` target | +|---|---|---| +| `INVOICE_OVERDUE` | an invoice passes its due date unpaid (`status = OVERDUE`) | the invoice | +| `REVISION_REQUESTED` | a client raises / is awaiting a revision (`status` `REQUESTED` or `IN_REVIEW`) | the revision request | +| `DELIVERABLE_REVIEW` | a deliverable is submitted and awaiting admin review / client sign-off | the deliverable's project | +| `MILESTONE_DUE` | a milestone is due soon or overdue | the milestone's project | + +- `title` — short headline (e.g. "Invoice overdue"). +- `body` — nullable one-line context (e.g. "Acme Co · $4,200 · 3 days late"). +- `href` — in-app deep link the bell navigates to on click. +- `entityType` — one of `invoice` | `revision` | `deliverable` | `milestone` | + `project` | `null`; `entityId` its id (for grouping / dedup). +- `createdAt` — ISO `date-time`. + +### 3c. Read state — **no endpoint needed** +Read/seen state is tracked **per-device on the client** (localStorage), so the +wire model intentionally has **no `read` field** and there is **no +mark-as-read endpoint** to build. If cross-device read state is wanted later, +add `read: boolean` to the model plus `PATCH /api/notifications/{id}/read` and +`POST /api/notifications/read-all`; the client can adopt them without a UI +change. + +--- + ## Related gaps (lower priority, same class) - **Project `updates`** are also write-only (`POST` / `DELETE`, no GET). If From db8251bd97f93b0d0f77db494875f7e24c1b804c Mon Sep 17 00:00:00 2001 From: Tanyalouise Date: Fri, 14 Aug 2026 16:53:44 +0100 Subject: [PATCH 3/8] =?UTF-8?q?docs(backend):=20client=20portal=20API=20re?= =?UTF-8?q?quests=20=E2=80=94=20portal=20invoices,=20deliverable=20review?= =?UTF-8?q?=20persistence,=20dashboard=20aggregates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/backend-requests.md | 96 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 3 deletions(-) diff --git a/docs/backend-requests.md b/docs/backend-requests.md index e636a8e..8ebe128 100644 --- a/docs/backend-requests.md +++ b/docs/backend-requests.md @@ -134,15 +134,101 @@ change. --- +## 4. Portal invoices — client-scoped read (blocking the portal invoices + billing) + +Invoices exist admin-side (`GET /api/projects/{id}/invoices`) but there is **no +portal equivalent**, so the portal's invoices section and the dashboard billing +snapshot can't load. `GET /api/portal/projects/{id}/invoices` currently **404s**. + +### New endpoint: `GET /api/portal/projects/{id}/invoices` +- **Auth:** `ClientBearer`, scoped to the client's own project. +- **Returns:** the project's **issued** invoices only — exclude `DRAFT` (drafts + stay internal to the studio) — sorted by `issuedDate` desc. +- **Response** (standard envelope, `data` is an `Invoice[]`): + +```json +{ + "success": true, + "message": "Invoices retrieved", + "data": [ + { + "id": "…", + "projectId": "…", + "invoiceNumber": "INV-0001", + "amount": 4200, + "currency": "USD", + "lineItems": [{ "description": "Design phase", "amount": 4200 }], + "description": null, + "status": "SENT", + "issuedDate": "2026-08-01T00:00:00.000Z", + "dueDate": "2026-08-15T00:00:00.000Z", + "paidDate": null, + "pdfUrl": "https://…/inv-0001.pdf", + "createdAt": "…", + "updatedAt": "…" + } + ] +} +``` +- **Errors:** `401` (no/invalid token), `404` (project not found). +- **Frontend status:** already built and calling this route; it treats a `404` + as an empty list, so the invoices section and dashboard billing light up + automatically once it ships. The path is already in `docs/api/openapi.json`, + marked `x-status: planned`. + +Mirror of the admin `GET /api/projects/{id}/invoices`, client-scoped and +draft-filtered. + +--- + +## 5. Return the client's review on portal deliverables (persist approve / request-changes) + +The review **write** path exists — `POST /api/portal/deliverables/{id}/review` — +but the deliverable **read** carries no review, so once a client approves or +requests changes the outcome is **lost on reload** (the portal only holds it in +session state). + +### Change: embed the client's latest review in the portal deliverables read +- **Endpoint:** `GET /api/portal/projects/{id}/deliverables`. +- **Change:** add **`review: DeliverableReview | null`** to each returned + `Deliverable` — the client's most recent review of that version: + +```json +{ + "status": "APPROVED", // or "CHANGES_REQUESTED" + "comment": "Looks great, ship it", // nullable + "reviewedAt": "2026-08-12T10:30:00.000Z" +} +``` +- Lets the portal show "You approved this" / "Changes requested" durably and hide + the review controls once a version has been acted on, instead of resetting them + on every reload. + +--- + +## 6. Client-wide aggregate reads for the dashboard (optional, perf) + +The client dashboard summarizes across **all** the client's projects, so it +currently fans the per-project reads out (`…/deliverables`, `…/invoices`, and +`…/{id}` for milestones) — an N+1 that grows with project count. Two +client-scoped list endpoints would collapse each to a single request: +- `GET /api/portal/deliverables` → the client's `READY` deliverables across all + their projects. +- `GET /api/portal/invoices` → the client's issued invoices across all their + projects. +- **Auth:** `ClientBearer`, scoped to the caller; item shapes identical to the + per-project reads (§4 for invoices). +- **Frontend status:** nice-to-have. The dashboard works today via fan-out — this + is purely a performance win as a client's portfolio grows. + +--- + ## Related gaps (lower priority, same class) - **Project `updates`** are also write-only (`POST` / `DELETE`, no GET). If milestones get a read path, do the same for updates (embed in the portal project response and/or `GET /api/projects/{id}/updates`) so the client "project updates" feed can work. -- **Portal invoices** — the PRD shows invoices in the portal, but there's no - `/api/portal/.../invoices` endpoint. A client-scoped read would light up an - invoices section on the portal. --- @@ -151,5 +237,9 @@ change. - Frontend is already staged for §1: `Project.milestones?: Milestone[]` exists in the client models and the admin editor reads it — a read path makes it durable instead of session-only. +- The **client portal** is fully built against §1b and §4–§5: the project + milestone timeline, the invoices section, the dashboard billing snapshot, and + deliverable review state are all wired and degrade gracefully (empty/quiet, no + errors) until these land. §6 is a later performance-only optimization. - Date fields are `date-time`; the frontend sends full ISO timestamps (a bare `yyyy-mm-dd` is rejected as an invalid datetime). From c2958a04fe3997afa4707248fc0166d9a218ff14 Mon Sep 17 00:00:00 2001 From: Tanyalouise Date: Thu, 20 Aug 2026 13:01:07 +0100 Subject: [PATCH 4/8] =?UTF-8?q?docs(backend):=20revision=20request=20flow?= =?UTF-8?q?=20=E2=80=94=20structural=20new-phase,=20decline=20reason,=20at?= =?UTF-8?q?tachments,=20deliverable=20link,=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/backend-requests.md | 99 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/docs/backend-requests.md b/docs/backend-requests.md index 8ebe128..eb6b368 100644 --- a/docs/backend-requests.md +++ b/docs/backend-requests.md @@ -223,6 +223,105 @@ client-scoped list endpoints would collapse each to a single request: --- +## 7. Revision request flow — make each decision durable, navigable, communicated + +The status machine works (`REQUESTED → IN_REVIEW → APPROVED/DECLINED`, mirrored on +both admin and portal), but the handoffs around it are thin. Today: +`POST /api/portal/projects/{id}/revision-requests` (client submit) → admin +`GET /api/revision-requests` → `PUT …/{id}/status` (in-review / decline) → +`POST …/{id}/approve` `{ type: "new_phase" | "new_project" }`. The requests below +close the gaps that leave a decision invisible or unexplained. + +### 7a. Approve "as new phase" must produce a structural, navigable result *(the main gap)* + +Approving `new_phase` today only writes a `resultingPhaseNote` **string** on the +revision — **nothing changes on the project**. There's no Phase entity, milestones +have no read path (§1), and the approve call carries no substance, so a client sees +"Approved" with no new phase, milestones, or timeline change. (`new_project` works +because it returns `resultingProjectId` the portal can link to.) + +Recommended contract for `POST /api/revision-requests/{id}/approve` when +`type: "new_phase"`: + +```json +{ + "type": "new_phase", + "phase": "Phase 2 — Rollout", // phase label, written to the new milestones (§2) + "endDate": "2026-11-30T00:00:00.000Z", // optional: extend the project's target finish + "milestones": [ // optional: milestones the new phase adds + { "title": "Rollout kickoff", "dueDate": "2026-10-01T00:00:00.000Z" } + ] +} +``` + +Backend effects (all on the **parent** project = `revision.projectId`): +- Create the supplied `milestones`, tagged with `phase` (needs `Milestone.phase`, §2), + appended after the current `order` max. +- If `endDate` is given, extend `Project.endDate`; optionally set `Project.phase` + to the new label. +- **Set `revision.resultingProjectId = revision.projectId`** (or a dedicated + `resultingPhaseProjectId`) so both surfaces can link to the project the phase + landed on — symmetric with `new_project`. +- Return the updated `RevisionRequest`. + +Depends on **§1** (milestone read) + **§2** (`Milestone.phase`) to be visible. +Once those land, the project timeline actually grows and the portal card can say +"View the updated project." + +> **Open decision:** whether the admin authors the phase's milestones **inline at +> approval** (payload above) or approves first and adds them afterward in the +> existing milestone editor. Either works with §1/§2; the payload fields are +> optional so the lighter path is supported. + +### 7b. Decline must capture a reason, shown to the client + +Decline today is `PUT …/{id}/status` `{ "status": "DECLINED" }` — no reason — so +the client sees a bare "Declined" with no explanation or next step. + +- Accept an optional **`decisionNote: string`** on the status update (and on + approve), stored on the `RevisionRequest` and returned in every read + (`GET /api/portal/revision-requests` included). +- The portal shows it under a declined/approved revision; the admin decline + dialog gains a reason field. +- Prefer a single nullable `decisionNote` for any terminal decision over the + current narrowly-named `resultingPhaseNote`. + +### 7c. Revision attachments — one shape end to end (`{ url, name }[]`) + +The client submits `attachments: [{ "url": "…", "name": "…" }]`, but the stored / +returned shape is inconsistent (admin reads `{ name, size }`), so the admin can't +open what the client attached. + +- Accept and persist `attachments: { url: string, name: string }[]` on + `POST /api/portal/projects/{id}/revision-requests`. +- Return the same shape on `GET /api/revision-requests` (admin) and + `GET /api/portal/revision-requests` (client). Admin detail links each to `url`. + +### 7d. Deliverable "request changes" → linked revision request + +PRD §1.2.6 wants a deliverable "request changes" to convert into a revision request +tied to that deliverable. Today `POST /api/portal/deliverables/{id}/review` +`{ status: "CHANGES_REQUESTED" }` and a revision request are unrelated records. + +- Add an optional **`deliverableId: string | null`** to `RevisionRequest` so a + change-request can reference the deliverable it came from. +- Decision for the backend: either **auto-create** a linked revision (status + `REQUESTED`) when a `CHANGES_REQUESTED` review is posted, or expose the link so + the admin can promote a review into a revision. Frontend can drive the manual + path once `deliverableId` exists. + +### 7e. Status-change notifications to the client (PRD §1.5) + +The client only learns of a decision by reopening the portal — there's no push. + +- Email the client on revision status transitions: **received** (`REQUESTED` ack), + **approved**, **declined** (include `decisionNote` from §7b). +- Notify the **admin** on a new `REQUESTED` — this is the `REVISION_REQUESTED` + notification type already specced in **§3b**; no new endpoint, just the trigger. +- Server-derived; no frontend endpoint beyond the existing admin notifications feed. + +--- + ## Related gaps (lower priority, same class) - **Project `updates`** are also write-only (`POST` / `DELETE`, no GET). If From 9529b6661c1d2aec0fd876cd175a33e59408bd78 Mon Sep 17 00:00:00 2001 From: Tanyalouise Date: Thu, 20 Aug 2026 13:07:34 +0100 Subject: [PATCH 5/8] docs(backend): make notification read state server-side (read field + mark-read endpoints) --- docs/backend-requests.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/backend-requests.md b/docs/backend-requests.md index eb6b368..435a91f 100644 --- a/docs/backend-requests.md +++ b/docs/backend-requests.md @@ -99,6 +99,7 @@ frontend is already built against the contract below and degrades gracefully "href": "/admin/invoices/inv_123", "entityType": "invoice", "entityId": "inv_123", + "read": false, "createdAt": "2026-08-14T09:00:00.000Z" } ] @@ -122,15 +123,29 @@ four `type`s the frontend renders, and the signal each is generated from: - `href` — in-app deep link the bell navigates to on click. - `entityType` — one of `invoice` | `revision` | `deliverable` | `milestone` | `project` | `null`; `entityId` its id (for grouping / dedup). +- `read` — `boolean`, whether the calling admin has read it (see §3c). - `createdAt` — ISO `date-time`. -### 3c. Read state — **no endpoint needed** -Read/seen state is tracked **per-device on the client** (localStorage), so the -wire model intentionally has **no `read` field** and there is **no -mark-as-read endpoint** to build. If cross-device read state is wanted later, -add `read: boolean` to the model plus `PATCH /api/notifications/{id}/read` and -`POST /api/notifications/read-all`; the client can adopt them without a UI -change. +### 3c. Read state — server-side, per admin + +Read/seen state lives **on the server**, per admin, so it's consistent across +devices and survives a cache clear (a `localStorage` flag drifts the moment the +admin opens a second browser). The model carries a **`read: boolean`** (§3b) and +two write endpoints keep it in sync: + +- **`PATCH /api/notifications/{id}/read`** — mark one notification read. + - **Auth:** `AdminBearer`, scoped to the calling admin (404 if it isn't theirs). + - **Body:** none required; optionally `{ "read": false }` to mark unread again. + - **Returns:** the updated `Notification`. +- **`POST /api/notifications/read-all`** — mark every one of the admin's + notifications read (the dropdown's "Mark all read"). + - **Auth:** `AdminBearer`, scoped to the caller. + - **Returns:** `{ "success": true, "message": "…", "data": { "updated": 4 } }`. + +The unread **count** for the header badge is derived client-side from the list +(`data.filter(n => !n.read).length`), so no separate count endpoint is needed +while the list is capped (§3a). The frontend currently tracks read state in +`localStorage` and will switch to these once they ship. --- From dfd121c1eb25408a828793ce106b7afa25057e1e Mon Sep 17 00:00:00 2001 From: Tanyalouise Date: Thu, 20 Aug 2026 13:10:01 +0100 Subject: [PATCH 6/8] =?UTF-8?q?docs(backend):=20finalize=20=C2=A77=20?= =?UTF-8?q?=E2=80=94=20inline=20milestone=20authoring=20on=20new-phase=20a?= =?UTF-8?q?pproval,=20manual=20deliverable=E2=86=92revision=20promotion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/backend-requests.md | 59 ++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/docs/backend-requests.md b/docs/backend-requests.md index 435a91f..8cec398 100644 --- a/docs/backend-requests.md +++ b/docs/backend-requests.md @@ -255,38 +255,46 @@ have no read path (§1), and the approve call carries no substance, so a client "Approved" with no new phase, milestones, or timeline change. (`new_project` works because it returns `resultingProjectId` the portal can link to.) -Recommended contract for `POST /api/revision-requests/{id}/approve` when -`type: "new_phase"`: +**Decided:** the admin authors the new phase **inline at approval** — the "As new +phase" action opens a small form (phase name, one or more milestones, optional new +end date) rather than a bare dropdown item, and the backend scaffolds it in one +call. The existing milestone editor still edits those milestones afterward. + +Contract for `POST /api/revision-requests/{id}/approve` when `type: "new_phase"`: ```json { "type": "new_phase", - "phase": "Phase 2 — Rollout", // phase label, written to the new milestones (§2) - "endDate": "2026-11-30T00:00:00.000Z", // optional: extend the project's target finish - "milestones": [ // optional: milestones the new phase adds - { "title": "Rollout kickoff", "dueDate": "2026-10-01T00:00:00.000Z" } - ] + "phase": "Phase 2 — Rollout", // required: phase label, written to the new milestones (§2) + "milestones": [ // required: at least one — the substance of the phase + { "title": "Rollout kickoff", "dueDate": "2026-10-01T00:00:00.000Z" }, + { "title": "Go-live", "dueDate": "2026-11-15T00:00:00.000Z" } + ], + "endDate": "2026-11-30T00:00:00.000Z" // optional: extend the project's target finish } ``` +Validation: +- `phase` non-empty and `milestones` has **≥ 1** entry (a phase with no milestones + is meaningless — reject with `400`). +- `milestones[].title` required; `dueDate` optional ISO `date-time`. + Backend effects (all on the **parent** project = `revision.projectId`): -- Create the supplied `milestones`, tagged with `phase` (needs `Milestone.phase`, §2), - appended after the current `order` max. -- If `endDate` is given, extend `Project.endDate`; optionally set `Project.phase` - to the new label. +- Create the supplied `milestones`, tagged with `phase` (needs `Milestone.phase`, + §2), status `PENDING`, appended after the current `order` max (preserving array + order). +- Set `Project.phase` to the new label; if `endDate` is given, extend + `Project.endDate` (only forward — ignore an earlier date). - **Set `revision.resultingProjectId = revision.projectId`** (or a dedicated `resultingPhaseProjectId`) so both surfaces can link to the project the phase landed on — symmetric with `new_project`. -- Return the updated `RevisionRequest`. +- Set status `APPROVED`; return the updated `RevisionRequest`. Depends on **§1** (milestone read) + **§2** (`Milestone.phase`) to be visible. Once those land, the project timeline actually grows and the portal card can say -"View the updated project." - -> **Open decision:** whether the admin authors the phase's milestones **inline at -> approval** (payload above) or approves first and adds them afterward in the -> existing milestone editor. Either works with §1/§2; the payload fields are -> optional so the lighter path is supported. +"View the updated project." **Frontend implication:** the admin approve control +changes from a plain dropdown item to a small form/dialog for the phase + its +milestones. ### 7b. Decline must capture a reason, shown to the client @@ -318,12 +326,17 @@ PRD §1.2.6 wants a deliverable "request changes" to convert into a revision req tied to that deliverable. Today `POST /api/portal/deliverables/{id}/review` `{ status: "CHANGES_REQUESTED" }` and a revision request are unrelated records. +**Decided: manual promotion, not auto-create.** The PRD says a change-request +*"can convert"* — opt-in, not automatic — and auto-raising a revision on every +"request changes" would flood the queue with tweaks that aren't scope changes. So: - Add an optional **`deliverableId: string | null`** to `RevisionRequest` so a - change-request can reference the deliverable it came from. -- Decision for the backend: either **auto-create** a linked revision (status - `REQUESTED`) when a `CHANGES_REQUESTED` review is posted, or expose the link so - the admin can promote a review into a revision. Frontend can drive the manual - path once `deliverableId` exists. + promoted request references the deliverable it came from. +- Accept `deliverableId` on the create bodies — + `POST /api/portal/projects/{id}/revision-requests` (client "request changes → + raise a revision") and admin promotion — and return it in every read so both + sides can show "from deliverable X". +- The `CHANGES_REQUESTED` deliverable review stays its own record; promotion is a + deliberate action, not a side effect of the review. ### 7e. Status-change notifications to the client (PRD §1.5) From 600c43b011423957a0eef094498d5651e7622903 Mon Sep 17 00:00:00 2001 From: Tanyalouise Date: Fri, 28 Aug 2026 12:10:37 +0100 Subject: [PATCH 7/8] docs(backend): cut to what's actually still open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of this list shipped. Leaving it as-is would have merged a doc that reads like an open ask list but is done — so it's now a shipped table plus the three requests that remain. Corrections to what this doc previously claimed: - §4 asked the backend to ADD GET /api/portal/projects/{id}/invoices. That endpoint always existed. The 404 we were seeing was a CORS rejection returning 500 with no CORS headers, which the browser surfaces as an opaque failure. That misreading is now §C2. - §5 is only half open. `review` is embedded, but the schema scopes it to portal reads, so the studio still can't see a verdict its own notifications told it about. Still open: - A. Populate `review` on the admin deliverable read. Blocks the one PRD §1.4 dashboard bullet with no panel. - B. Validation errors must name the failing field. `{"success":false, "message":"Required"}` can't drive form highlighting, and cost real time on the invoice draft bug. - C. Allowlist http://clients.localhost:3000, and reject with 403 not 500. Currently blocks portal sign-in at the documented local dev URL. Each has a curl repro. Also notes the version-string trap: info.version read 3.0.0 both before and after six endpoints were added. --- docs/backend-requests.md | 431 ++++++++++----------------------------- 1 file changed, 111 insertions(+), 320 deletions(-) diff --git a/docs/backend-requests.md b/docs/backend-requests.md index 8cec398..4c394f9 100644 --- a/docs/backend-requests.md +++ b/docs/backend-requests.md @@ -1,372 +1,163 @@ # Backend requests Changes the frontend needs from the Clover CMS API. Written against the live -spec **v3.0.0** (`https://api.cloverdesign.xyz/docs.json`). Each is additive and -low-risk; the `Milestone` / `ProjectUpdate` data models already exist. +spec at `https://api.cloverdesign.xyz/docs.json`. ---- +**Most of the original list has shipped** — see the table below. Three asks are +still open, and two of them block work that is otherwise finished. -## 1. Expose milestones for reading (blocking the milestone timeline) +> A note on checking this: `info.version` reads **3.0.0** both before and after +> six endpoints were added. The version string is not a change signal. Diff the +> paths against `docs/api/openapi.json`, or fetch `docs.json` and compare. -Today milestones are **write-only**: `POST` / `PUT` / `DELETE` exist under -`/api/projects/{id}/milestones`, but **no endpoint returns them** — they aren't -in any GET response and aren't embedded in the project. So a milestone is only -visible in the immediate response of the call that created/updated it; it can't -be re-read on reload or shown in the client portal. +--- -### 1a. Admin — new endpoint: `GET /api/projects/{id}/milestones` -- **Auth:** `AdminBearer` (approved admin), same as the milestone write endpoints. -- **Returns:** the project's milestones, **sorted by `order` ascending**. -- **Response** (standard envelope, `data` is a `Milestone[]`): +## Shipped -```json -{ - "success": true, - "message": "Milestones retrieved", - "data": [ - { - "id": "…", - "projectId": "…", - "title": "Design handoff", - "description": null, - "status": "PENDING", - "order": 0, - "dueDate": "2026-08-14T00:00:00.000Z", - "completedAt": null, - "phase": "Design", - "createdAt": "…", - "updatedAt": "…" - } - ] -} -``` -- **Errors:** `401` (no/invalid token), `404` (project not found). +Verified against the live spec on 2026-08-28, and against a live portal session +where the spec alone couldn't settle it. -### 1b. Portal — embed `milestones` in the project response -- **Endpoint:** `GET /api/portal/projects/{id}` (and `GET /api/portal/projects` - list items if cheap). -- **Auth:** `ClientBearer`, scoped to the client's own project (unchanged). -- **Change:** add a **`milestones: Milestone[]`** array to the returned `Project` - `data`, sorted by `order`. The portal has no separate milestones endpoint and - shouldn't need one — embedding keeps the client timeline to a single request. +| # | Ask | Delivered as | +|---|---|---| +| 1a | Admin milestone read path | `GET /api/projects/{id}/milestones` | +| 1b | Portal embeds milestones | `GET /api/portal/projects` embeds `milestones`, `updates`, `invoices` | +| 2 | `phase` on `Milestone` | `Milestone.phase` | +| 3a | Notifications feed | `GET /api/notifications` | +| 3b | Notification model | matches, plus `read` | +| 3c | Server-side read state | `PATCH /api/notifications/{id}/read`, `POST /api/notifications/read-all` | +| 4 | Portal invoices read | `GET /api/portal/projects/{id}/invoices` — this one was always there; the 404 we saw was CORS, see §C | +| 5 | Review on portal deliverables | `Deliverable.review`, portal reads only — **partially**, see §A | +| 6 | Client-wide aggregate reads | `GET /api/portal/invoices`, `GET /api/portal/deliverables` | +| 7a | Approve-as-new-phase produces structure | `POST …/approve` takes `phase`, `milestones[]`, `endDate` | +| 7b | Decline captures a reason | `decisionNote` on `PUT …/status` and `POST …/approve` | +| 7c | Attachments as `{ url, name }[]` | matches | +| 7d | Deliverable → linked revision | `deliverableId` on the portal revision submit | + +Also landed unasked, and equally useful: `GET /api/projects/{id}/updates`, which +closes the "Related gaps" note in the previous version of this doc. + +The frontend consumes all of the above as of PR #12. --- -## 2. Add a `phase` field to `Milestone` (to group milestones by phase) - -Milestones and phases are currently unrelated: `Milestone` has no `phase`, and -`Project.phase` is a single string (the project's current stage). To show -milestones grouped under phases (Discovery → Design → Development → Launch), add -a phase to the milestone. Free-text string, mirroring `Project.phase` (nullable) -— no new enum/validation needed. - -- **Model:** add nullable `phase: string` to `Milestone` (+ migration). -- **Write bodies:** accept optional `phase` on - `POST /api/projects/{id}/milestones` and `PUT …/{milestoneId}`. -- **Response:** include `phase` in the `Milestone` returned everywhere (create, - update, and the read path in §1). +## A. Populate `review` on the **admin** deliverable read -Once §1 + §2 land, the admin milestone editor and a portal milestone timeline -both become real, grouped by phase, with per-phase progress possible. +The one ask from §5 that didn't fully land. `DeliverableReview` exists and is +embedded — but the schema scopes it: ---- - -## 3. Notifications feed (header bell + attention badges) +> `review` — *"only populated on client portal reads +> (`GET /api/portal/projects/{id}/deliverables` and `GET /api/portal/deliverables`)"* -The admin shell now has a notifications bell and needs a server-generated -attention feed. There is **no notification model or endpoint today**. The -frontend is already built against the contract below and degrades gracefully -(empty "all caught up" state, no errors) until it ships. +So the client's verdict is visible to the client and invisible to the studio. -### 3a. New endpoint: `GET /api/notifications` -- **Auth:** `AdminBearer` (approved admin). Scoped to the calling admin. -- **Returns:** the admin's notifications, **newest first** (`createdAt` desc). - A reasonable cap (e.g. the most recent 50) is fine — the UI is a dropdown, - not an archive. -- **Polling:** the client refetches every ~60s; no websocket/SSE required. -- **Response** (standard envelope, `data` is a `Notification[]`): +**Why this blocks something concrete.** PRD §1.4 lists five things on the admin +dashboard: active projects, pending invoices, upcoming milestones, pending +revision requests, and **pending deliverable reviews**. The first four are built. +The fifth has no panel and can't have one — there is no admin-readable source for +it. `components/admin/deliverables/deliverables-list.tsx` carries the reason as a +comment. -```json -{ - "success": true, - "message": "Notifications retrieved", - "data": [ - { - "id": "…", - "type": "INVOICE_OVERDUE", - "title": "Invoice overdue", - "body": "Acme Co · $4,200 · 3 days late", - "href": "/admin/invoices/inv_123", - "entityType": "invoice", - "entityId": "inv_123", - "read": false, - "createdAt": "2026-08-14T09:00:00.000Z" - } - ] -} -``` -- **Errors:** `401` (no/invalid token). +**The inconsistency worth naming:** `NotificationType` already includes +`DELIVERABLE_REVIEW`, and `GET /api/notifications` documents it as created "at the +moment those events happen, for every approved admin". So the API tells an admin +a review happened, then offers no way to read what it said. -### 3b. Notification model -Server **derives** these from existing domain signals (no admin authoring). The -four `type`s the frontend renders, and the signal each is generated from: +**Ask:** populate `review` on `GET /api/projects/{id}/deliverables` (admin), same +shape as the portal read. A separate `GET /api/deliverables/{id}/review` would +also work; embedding is preferred since the list is what the dashboard needs. -| `type` | Generated when | `href` target | -|---|---|---| -| `INVOICE_OVERDUE` | an invoice passes its due date unpaid (`status = OVERDUE`) | the invoice | -| `REVISION_REQUESTED` | a client raises / is awaiting a revision (`status` `REQUESTED` or `IN_REVIEW`) | the revision request | -| `DELIVERABLE_REVIEW` | a deliverable is submitted and awaiting admin review / client sign-off | the deliverable's project | -| `MILESTONE_DUE` | a milestone is due soon or overdue | the milestone's project | - -- `title` — short headline (e.g. "Invoice overdue"). -- `body` — nullable one-line context (e.g. "Acme Co · $4,200 · 3 days late"). -- `href` — in-app deep link the bell navigates to on click. -- `entityType` — one of `invoice` | `revision` | `deliverable` | `milestone` | - `project` | `null`; `entityId` its id (for grouping / dedup). -- `read` — `boolean`, whether the calling admin has read it (see §3c). -- `createdAt` — ISO `date-time`. - -### 3c. Read state — server-side, per admin - -Read/seen state lives **on the server**, per admin, so it's consistent across -devices and survives a cache clear (a `localStorage` flag drifts the moment the -admin opens a second browser). The model carries a **`read: boolean`** (§3b) and -two write endpoints keep it in sync: - -- **`PATCH /api/notifications/{id}/read`** — mark one notification read. - - **Auth:** `AdminBearer`, scoped to the calling admin (404 if it isn't theirs). - - **Body:** none required; optionally `{ "read": false }` to mark unread again. - - **Returns:** the updated `Notification`. -- **`POST /api/notifications/read-all`** — mark every one of the admin's - notifications read (the dropdown's "Mark all read"). - - **Auth:** `AdminBearer`, scoped to the caller. - - **Returns:** `{ "success": true, "message": "…", "data": { "updated": 4 } }`. - -The unread **count** for the header badge is derived client-side from the list -(`data.filter(n => !n.read).length`), so no separate count endpoint is needed -while the list is capped (§3a). The frontend currently tracks read state in -`localStorage` and will switch to these once they ship. +Frontend is staged for this — `Deliverable.review?: DeliverableReview | null` is +already modelled, with the portal-only restriction documented on the type. --- -## 4. Portal invoices — client-scoped read (blocking the portal invoices + billing) +## B. Validation errors must name the failing field -Invoices exist admin-side (`GET /api/projects/{id}/invoices`) but there is **no -portal equivalent**, so the portal's invoices section and the dashboard billing -snapshot can't load. `GET /api/portal/projects/{id}/invoices` currently **404s**. +Every 400 is the same two keys, with no indication of which input was wrong: -### New endpoint: `GET /api/portal/projects/{id}/invoices` -- **Auth:** `ClientBearer`, scoped to the client's own project. -- **Returns:** the project's **issued** invoices only — exclude `DRAFT` (drafts - stay internal to the studio) — sorted by `issuedDate` desc. -- **Response** (standard envelope, `data` is an `Invoice[]`): - -```json -{ - "success": true, - "message": "Invoices retrieved", - "data": [ - { - "id": "…", - "projectId": "…", - "invoiceNumber": "INV-0001", - "amount": 4200, - "currency": "USD", - "lineItems": [{ "description": "Design phase", "amount": 4200 }], - "description": null, - "status": "SENT", - "issuedDate": "2026-08-01T00:00:00.000Z", - "dueDate": "2026-08-15T00:00:00.000Z", - "paidDate": null, - "pdfUrl": "https://…/inv-0001.pdf", - "createdAt": "…", - "updatedAt": "…" - } - ] -} ``` -- **Errors:** `401` (no/invalid token), `404` (project not found). -- **Frontend status:** already built and calling this route; it treats a `404` - as an empty list, so the invoices section and dashboard billing light up - automatically once it ships. The path is already in `docs/api/openapi.json`, - marked `x-status: planned`. - -Mirror of the admin `GET /api/projects/{id}/invoices`, client-scoped and -draft-filtered. - ---- +$ curl -sX POST https://api.cloverdesign.xyz/api/portal/request-otp \ + -H 'Content-Type: application/json' -d '{}' +{"success":false,"message":"Required"} +``` -## 5. Return the client's review on portal deliverables (persist approve / request-changes) +The `Error` schema is `{ success, message }` — there is nowhere for a field name +to go. -The review **write** path exists — `POST /api/portal/deliverables/{id}/review` — -but the deliverable **read** carries no review, so once a client approves or -requests changes the outcome is **lost on reload** (the portal only holds it in -session state). +**Why this is not cosmetic.** A form receiving `"Required"` cannot highlight the +input at fault, so the user is told something is wrong and not what. It also +costs real debugging time: a bug where invoice drafts couldn't be created +returned exactly this, and pinning it down took a probe matrix against a +nonexistent project id rather than reading the response. -### Change: embed the client's latest review in the portal deliverables read -- **Endpoint:** `GET /api/portal/projects/{id}/deliverables`. -- **Change:** add **`review: DeliverableReview | null`** to each returned - `Deliverable` — the client's most recent review of that version: +**Ask:** add a field-level list to the error envelope on validation failures. +Shape is the backend's call; anything addressable works: ```json { - "status": "APPROVED", // or "CHANGES_REQUESTED" - "comment": "Looks great, ship it", // nullable - "reviewedAt": "2026-08-12T10:30:00.000Z" + "success": false, + "message": "Validation failed", + "errors": [ + { "field": "dueDate", "message": "Required" }, + { "field": "lineItems.0.unitPrice", "message": "Expected number" } + ] } ``` -- Lets the portal show "You approved this" / "Changes requested" durably and hide - the review controls once a version has been acted on, instead of resetting them - on every reload. - ---- -## 6. Client-wide aggregate reads for the dashboard (optional, perf) - -The client dashboard summarizes across **all** the client's projects, so it -currently fans the per-project reads out (`…/deliverables`, `…/invoices`, and -`…/{id}` for milestones) — an N+1 that grows with project count. Two -client-scoped list endpoints would collapse each to a single request: -- `GET /api/portal/deliverables` → the client's `READY` deliverables across all - their projects. -- `GET /api/portal/invoices` → the client's issued invoices across all their - projects. -- **Auth:** `ClientBearer`, scoped to the caller; item shapes identical to the - per-project reads (§4 for invoices). -- **Frontend status:** nice-to-have. The dashboard works today via fan-out — this - is purely a performance win as a client's portfolio grows. +Keeping the existing `message` as a human-readable summary is fine — the frontend +already renders it. `errors` would be additive and ignored until wired up. --- -## 7. Revision request flow — make each decision durable, navigable, communicated +## C. CORS: allowlist the local portal host, and reject with 403 not 500 -The status machine works (`REQUESTED → IN_REVIEW → APPROVED/DECLINED`, mirrored on -both admin and portal), but the handoffs around it are thin. Today: -`POST /api/portal/projects/{id}/revision-requests` (client submit) → admin -`GET /api/revision-requests` → `PUT …/{id}/status` (in-review / decline) → -`POST …/{id}/approve` `{ type: "new_phase" | "new_project" }`. The requests below -close the gaps that leave a decision invisible or unexplained. +Two separate problems, one of which currently blocks portal development. -### 7a. Approve "as new phase" must produce a structural, navigable result *(the main gap)* +**C1 — `http://clients.localhost:3000` isn't allowlisted.** The portal is served +from the `clients.` subdomain by design (`proxy.ts`; production +`clients.cloverdesign.xyz`, local `clients.localhost:3000`, which browsers +resolve to loopback per RFC 6761). The production host is allowed. The local one +is not, so **portal sign-in fails at the documented local dev URL** — the OTP +request never leaves the browser and the client sees only "Couldn't send a code." -Approving `new_phase` today only writes a `resultingPhaseNote` **string** on the -revision — **nothing changes on the project**. There's no Phase entity, milestones -have no read path (§1), and the approve call carries no substance, so a client sees -"Approved" with no new phase, milestones, or timeline change. (`new_project` works -because it returns `resultingProjectId` the portal can link to.) - -**Decided:** the admin authors the new phase **inline at approval** — the "As new -phase" action opens a small form (phase name, one or more milestones, optional new -end date) rather than a bare dropdown item, and the backend scaffolds it in one -call. The existing milestone editor still edits those milestones afterward. - -Contract for `POST /api/revision-requests/{id}/approve` when `type: "new_phase"`: - -```json -{ - "type": "new_phase", - "phase": "Phase 2 — Rollout", // required: phase label, written to the new milestones (§2) - "milestones": [ // required: at least one — the substance of the phase - { "title": "Rollout kickoff", "dueDate": "2026-10-01T00:00:00.000Z" }, - { "title": "Go-live", "dueDate": "2026-11-15T00:00:00.000Z" } - ], - "endDate": "2026-11-30T00:00:00.000Z" // optional: extend the project's target finish -} +``` +Origin preflight +http://clients.localhost:3000 500 ← no allow-origin header +http://localhost:3000 204 +https://clients.cloverdesign.xyz 204 ``` -Validation: -- `phase` non-empty and `milestones` has **≥ 1** entry (a phase with no milestones - is meaningless — reject with `400`). -- `milestones[].title` required; `dueDate` optional ISO `date-time`. - -Backend effects (all on the **parent** project = `revision.projectId`): -- Create the supplied `milestones`, tagged with `phase` (needs `Milestone.phase`, - §2), status `PENDING`, appended after the current `order` max (preserving array - order). -- Set `Project.phase` to the new label; if `endDate` is given, extend - `Project.endDate` (only forward — ignore an earlier date). -- **Set `revision.resultingProjectId = revision.projectId`** (or a dedicated - `resultingPhaseProjectId`) so both surfaces can link to the project the phase - landed on — symmetric with `new_project`. -- Set status `APPROVED`; return the updated `RevisionRequest`. - -Depends on **§1** (milestone read) + **§2** (`Milestone.phase`) to be visible. -Once those land, the project timeline actually grows and the portal card can say -"View the updated project." **Frontend implication:** the admin approve control -changes from a plain dropdown item to a small form/dialog for the phase + its -milestones. - -### 7b. Decline must capture a reason, shown to the client - -Decline today is `PUT …/{id}/status` `{ "status": "DECLINED" }` — no reason — so -the client sees a bare "Declined" with no explanation or next step. - -- Accept an optional **`decisionNote: string`** on the status update (and on - approve), stored on the `RevisionRequest` and returned in every read - (`GET /api/portal/revision-requests` included). -- The portal shows it under a declined/approved revision; the admin decline - dialog gains a reason field. -- Prefer a single nullable `decisionNote` for any terminal decision over the - current narrowly-named `resultingPhaseNote`. - -### 7c. Revision attachments — one shape end to end (`{ url, name }[]`) - -The client submits `attachments: [{ "url": "…", "name": "…" }]`, but the stored / -returned shape is inconsistent (admin reads `{ name, size }`), so the admin can't -open what the client attached. - -- Accept and persist `attachments: { url: string, name: string }[]` on - `POST /api/portal/projects/{id}/revision-requests`. -- Return the same shape on `GET /api/revision-requests` (admin) and - `GET /api/portal/revision-requests` (client). Admin detail links each to `url`. - -### 7d. Deliverable "request changes" → linked revision request - -PRD §1.2.6 wants a deliverable "request changes" to convert into a revision request -tied to that deliverable. Today `POST /api/portal/deliverables/{id}/review` -`{ status: "CHANGES_REQUESTED" }` and a revision request are unrelated records. - -**Decided: manual promotion, not auto-create.** The PRD says a change-request -*"can convert"* — opt-in, not automatic — and auto-raising a revision on every -"request changes" would flood the queue with tweaks that aren't scope changes. So: -- Add an optional **`deliverableId: string | null`** to `RevisionRequest` so a - promoted request references the deliverable it came from. -- Accept `deliverableId` on the create bodies — - `POST /api/portal/projects/{id}/revision-requests` (client "request changes → - raise a revision") and admin promotion — and return it in every read so both - sides can show "from deliverable X". -- The `CHANGES_REQUESTED` deliverable review stays its own record; promotion is a - deliberate action, not a side effect of the review. - -### 7e. Status-change notifications to the client (PRD §1.5) - -The client only learns of a decision by reopening the portal — there's no push. - -- Email the client on revision status transitions: **received** (`REQUESTED` ack), - **approved**, **declined** (include `decisionNote` from §7b). -- Notify the **admin** on a new `REQUESTED` — this is the `REVISION_REQUESTED` - notification type already specced in **§3b**; no new endpoint, just the trigger. -- Server-derived; no frontend endpoint beyond the existing admin notifications feed. +**C2 — a rejected origin returns 500.** The 500 path emits no CORS headers, so +the browser reports an opaque network failure rather than a CORS error. That is +what made C1 read as "the endpoint 404s" for a while, and it is why this doc +previously claimed `GET /api/portal/projects/{id}/invoices` didn't exist. It +always did. ---- +**Ask:** +1. Add `http://clients.localhost:3000` to the allowlist. +2. Reject a disallowed origin with **403** and the CORS headers still attached, + so the browser surfaces an actionable error. -## Related gaps (lower priority, same class) +Repro: -- **Project `updates`** are also write-only (`POST` / `DELETE`, no GET). If - milestones get a read path, do the same for updates (embed in the portal - project response and/or `GET /api/projects/{id}/updates`) so the client - "project updates" feed can work. +```bash +curl -sD - -o /dev/null -X OPTIONS \ + https://api.cloverdesign.xyz/api/portal/request-otp \ + -H 'Origin: http://clients.localhost:3000' \ + -H 'Access-Control-Request-Method: POST' +``` --- ## Notes -- Frontend is already staged for §1: `Project.milestones?: Milestone[]` exists in - the client models and the admin editor reads it — a read path makes it durable - instead of session-only. -- The **client portal** is fully built against §1b and §4–§5: the project - milestone timeline, the invoices section, the dashboard billing snapshot, and - deliverable review state are all wired and degrade gracefully (empty/quiet, no - errors) until these land. §6 is a later performance-only optimization. - Date fields are `date-time`; the frontend sends full ISO timestamps (a bare `yyyy-mm-dd` is rejected as an invalid datetime). +- The `Project` schema documents none of its embedded relations, though the + endpoint summaries promise them. The summaries are right. Worth fixing so the + schema can be trusted on its own. +- `GET /api/portal/projects` returns an undocumented `_count` on each item. + Harmless, but it should either be in the schema or dropped. +- Priority, if it helps: **A** and **B** block or degrade shipped features; **C** + blocks local portal work but has a workaround (sign in at + `http://localhost:3000/portal/login`, which is allowlisted). From b53665278a9aca231bfd3ebdcda09655d27ebaa6 Mon Sep 17 00:00:00 2001 From: Tanyalouise Date: Fri, 28 Aug 2026 12:20:07 +0100 Subject: [PATCH 8/8] =?UTF-8?q?docs(backend):=20give=20=C2=A7C=20the=20tes?= =?UTF-8?q?ted=20origin=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three origins are allowed today; listing exactly which and what each is for, rather than asking Patrick to infer it. Also asks for a loopback pattern instead of enumerating dev ports, with a note on why that's low risk under Bearer auth and when to revisit. Records what is NOT needed, so it doesn't get added speculatively: no Vercel preview origins (testing is against main) and no marketing-site origin until that site actually calls the API from the browser. --- docs/backend-requests.md | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/backend-requests.md b/docs/backend-requests.md index 4c394f9..ade3c91 100644 --- a/docs/backend-requests.md +++ b/docs/backend-requests.md @@ -133,10 +133,41 @@ what made C1 read as "the endpoint 404s" for a while, and it is why this doc previously claimed `GET /api/portal/projects/{id}/invoices` didn't exist. It always did. -**Ask:** -1. Add `http://clients.localhost:3000` to the allowlist. -2. Reject a disallowed origin with **403** and the CORS headers still attached, - so the browser surfaces an actionable error. +**Ask 1 — the allowlist.** Three origins are allowed today. Tested 2026-08-28: + +| Origin | Preflight | | +|---|---|---| +| `http://localhost:3000` | 204 | local admin, and portal at `/portal/*` | +| `https://admin.cloverdesign.xyz` | 204 | admin production | +| `https://clients.cloverdesign.xyz` | 204 | portal production | +| `http://clients.localhost:3000` | **500** | **local portal — add this** | +| `http://localhost:3001` | **500** | Next's port fallback | +| `http://clients.localhost:3001` | **500** | same, portal | +| `http://127.0.0.1:3000` | **500** | distinct origin from `localhost` | + +Minimum to unblock local portal work: + +``` +http://clients.localhost:3000 +``` + +Preferably, rather than enumerating ports: **allow any `localhost`, +`*.localhost` or `127.0.0.1` origin on any port.** Next auto-increments the dev +port whenever 3000 is taken, so 3001 comes up constantly, and `127.0.0.1` is a +separate origin from `localhost` despite being the same machine. + +The risk of allowing loopback origins is low here specifically: auth is a Bearer +token read from `localStorage`, and a page on another origin cannot read it. +This would not hold if auth ever moves to cookies — revisit it then. + +No other origins are needed. Vercel preview deployments are not in use (testing +happens against `main`), and the marketing site does not call the API from the +browser yet — when it does, `https://cloverdesign.xyz` and +`https://www.cloverdesign.xyz` will need adding, but only if it fetches +client-side rather than at build time. + +**Ask 2 — reject with 403, not 500,** and keep the CORS headers attached, so the +browser surfaces an actionable error instead of an opaque network failure. Repro: