From 3a329abea0521c276d2f7388cf14bb01d0d5f08c Mon Sep 17 00:00:00 2001 From: David Taing Date: Sat, 22 Aug 2026 17:42:22 +1000 Subject: [PATCH 1/2] Save a practitioner's own profile to Postgres (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The practitioner half of #14. `/profile` read fixtures and wrote nothing; it now reads the signed-in practitioner's own rows and saves them back through a Server Action. The admin half landed in #122, so this references #14 and does not close it — what is left there is the claim path for a curated profile. Nothing here is a migration. Every grant, policy, guard and function this needs already existed, `my_profile()` and `my_credentials()` included; the work was entirely on the application side. ## The read `readOwnProfile` is two `security definer` RPCs and three ordinary selects, through the per-request client in `@/lib/supabase/server` — never the module-scoped anonymous one, which must never hold a session. The RPCs are not a stylistic choice and `20260822050002_profile_own_reads.sql` argues both at length: a bare select on `practitioners` returns every approved profile plus mine, and narrowing it means filtering on `user_id`, which `authenticated` may not read; and the owner needs the raw `evidence_url`, which no role below `bluehex_admin` holds a grant on. The other three tables need neither, because there the owner's policy is the only one `authenticated` has and row level security has already narrowed the answer. Nothing filters on ownership. A `where` clause here would read like the control and be a duplicate of one, which is worse than either. A practitioner with no profile gets an empty draft with their account address prefilled and nothing else — the spec's rule for the self-service path, and a default rather than a fact, since `contact_email` is where enquiries go and `auth.users.email` is a login identity. ## The write `saveProfileAction` is one Server Action. Creating a profile is two requests, contact first, because `practitioners.contact_id` is `not null unique` — the failure mode is the harmless one the spec accepts, a contact row nobody references rather than a published profile nobody can reach, and the message says the retry is safe rather than leaving somebody to guess. Every column is named on the way in, and the two that identify the writer come from the session rather than from the payload. Spreading the payload would work today, because the grant list refuses anything extra, but it would fail with `42501` rather than being impossible. The child tables reconcile through `profile-plan.ts`. A credential is keyed on `catalogue_id` rather than on its row id, because `unique (practitioner_id, catalogue_id)` is what makes that a key — keyed on the row id, repicking one credential and adding the entry it was changed to is an insert of a row that already exists. Deletes go first for the same class of reason: `practitioner_services_cap` counts rows, so swapping a service inserts into a full table otherwise. An unchanged credential is left alone entirely, so `updated_at` still answers when the row last changed. A free-text service row is never removed. The chips render the closed vocabulary and a labelled row is an admin's, written during curated intake, so reading its absence from the payload as a deletion would delete it on the first save of an unrelated field. Failures come back as values. A thrown error out of a Server Action is replaced with a generic message in a production build, and the specific one is what a practitioner can act on. ## What was checked, and how Unit tests cover both pure halves — 25 assertions over the mapping and the plan, including that a blank profile prefills the address and nothing else. Against the local stack, signed in as the seeded practitioner: the editor renders her real profile, contacts, services and credentials with their per-credential checks; the save lands; the two unchanged credentials keep their `updated_at` and their checks. Replaying the same statements directly against PostgREST, `verified` and `status` are both refused with `42501`, a credential is born unverified, and editing one that Bluehex had checked clears the check — the guard doing its job through this path rather than only in a test. ## The seam is gone rather than moved `profile-save.ts` held `submitProfile`, which returned a refusal saying the write was not built. It is now the two types the page and the client editor share, because a `"use server"` module may export nothing but async functions. `profile-fixtures.ts` keeps the catalogue the unit tests read and loses the example draft. The editor takes the action as a prop and a flag for whether a row exists behind the form, which is what decides the button copy and stops the Review step telling somebody they are waiting on Bluehex before they have submitted anything. --- AGENTS.md | 5 +- src/app/profile/_lib/actions.ts | 299 ++++++++++++++++++ src/app/profile/_lib/profile-mapping.test.ts | 217 +++++++++++++ src/app/profile/_lib/profile-mapping.ts | 196 ++++++++++++ src/app/profile/_lib/profile-plan.test.ts | 168 ++++++++++ src/app/profile/_lib/profile-plan.ts | 154 +++++++++ src/app/profile/_lib/profile-read.ts | 103 ++++++ src/app/profile/page.tsx | 81 +++-- .../profile-editor/profile-editor.tsx | 30 +- .../profile-editor/profile-form.tsx | 51 ++- src/lib/profile-fixtures.ts | 106 +------ src/lib/profile-save.ts | 50 ++- 12 files changed, 1296 insertions(+), 164 deletions(-) create mode 100644 src/app/profile/_lib/actions.ts create mode 100644 src/app/profile/_lib/profile-mapping.test.ts create mode 100644 src/app/profile/_lib/profile-mapping.ts create mode 100644 src/app/profile/_lib/profile-plan.test.ts create mode 100644 src/app/profile/_lib/profile-plan.ts create mode 100644 src/app/profile/_lib/profile-read.ts diff --git a/AGENTS.md b/AGENTS.md index da35873..855600c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -295,9 +295,10 @@ Files written before this rule are still hard-wrapped. Reflow a paragraph when y - `src/app/` — App Router routes, layout, and global styles. Pages are React Server Components by default. - `src/app/page.tsx` — the home page: hero, then the practitioner directory. +- `src/app/profile/_lib/` and `src/app/admin/_lib/` — the two halves of self-service, each beside the route that uses it and neither reachable from a client component. `profile-read.ts` is the practitioner's own read (two `security definer` RPCs and three ordinary selects), `actions.ts` is the Server Action that writes it back, and `profile-mapping.ts` and `profile-plan.ts` are the pure halves — rows to draft, and draft to the inserts, updates and deletes a save makes on the child tables. The admin side is the same shape against `bluehex_admin`. - `src/components/` — shared chrome and UI primitives. `practitioner-directory.tsx` is the one client component, because search and filters are local state. -- `src/lib/` — data and configuration, no rendering. `site.ts` is the single source of truth for naming, nav, contact details and legal links. `practitioners.ts` is the **public view model** — the shapes `anon` is granted, the two derivations and the two string helpers, and no rows since #53. `directory.ts` is the read that fills it and `directory-mapping.ts` is the pure half of that read, split so the ordering and null rules can be asserted without a stack. `database.types.ts` is **generated** — regenerate it with `pnpm db:types` after a migration rather than editing it. +- `src/lib/` — data and configuration, no rendering. `site.ts` is the single source of truth for naming, nav, contact details and legal links. `profile-draft.ts` is the editor's model — the practitioner-writable field set, the derivations over it and `toWritePayload`, which is where `"" → null` and `"" → do not submit this row` happen; `profile-validation.ts` is what the form checks before any of it travels, and `profile-save.ts` is the two types the page and the client editor share so a `"use server"` module does not have to export one. `practitioners.ts` is the **public view model** — the shapes `anon` is granted, the two derivations and the two string helpers, and no rows since #53. `directory.ts` is the read that fills it and `directory-mapping.ts` is the pure half of that read, split so the ordering and null rules can be asserted without a stack. `database.types.ts` is **generated** — regenerate it with `pnpm db:types` after a migration rather than editing it. - `src/lib/supabase/` — **three** clients, and which one you reach for is a correctness question rather than a style one. `anon.ts` is the module-scoped anonymous client, cached for the life of the process and therefore never allowed to hold a session; `server.ts` builds one client per server request from that request's cookies; `browser.ts` is the singleton the browser signs in through. `env.ts` reads the two environment variables, at call time so `next build` survives their absence. - `src/lib/auth/` — who is asking. `claims.ts` reads the `bluehex_admin` role off a verified token, `routes.ts` is the pure decision about which paths need what, `session.ts` is the server-side guard a protected page opens with (`requireAccount` / `requireAdmin`), and `actions.ts` holds the sign-out Server Action. None of it authorises anything — Postgres does that. - `src/proxy.ts` — refreshes the Supabase session on every matched request and turns signed-out visitors away. **`proxy.ts`, not `middleware.ts`**: Next.js 16 renamed the convention and only one such file is allowed per project. @@ -344,7 +345,7 @@ What exists: the local Supabase stack, the three clients in `src/lib/supabase/`, **The first query landed in #53**, and it is the public one: the home page reads approved profiles with their credentials and services, `/p/` reads one of them against the whole credential catalogue, and `/contact?about=` resolves a name. All of it goes through `src/lib/directory.ts` and the anonymous client, so every row that comes back is a row row level security decided a visitor may see — nothing filters on `status`, because `anon` cannot read it and the policy is the filter. Before that a health-check table existed briefly to prove the connection and was taken back out before it was ever committed, because it would have sat in the migration history permanently, describing a table dropped a fortnight later, to prove something the first real query proves for free. -What does not exist yet: `withdraw_profile()` and the erasure path (#52). `credential_catalogue.updated_at` **is** maintained on update as of #50 — `catalogue_guard` is what bumps it, its body reads `practitioner_credentials`, and a plpgsql body resolves its names at call time, so it could not be written before that table existed. **Auth landed with #83**: magic link only, so there is no password and no password reset; `@supabase/ssr` carries the session in cookies; `src/proxy.ts` refreshes it; and the `bluehex_admin` claim is read by the application. What that unblocks is that every policy in the spec is written against `auth.uid()`, and `auth.uid()` now resolves for a request the app makes on a signed-in person's behalf. Nothing writes through one yet — that is #14. The rest of this section is the contract for building those — treat it as binding, not as a description of current state. +What does not exist yet: `withdraw_profile()` and the erasure path (#52). `credential_catalogue.updated_at` **is** maintained on update as of #50 — `catalogue_guard` is what bumps it, its body reads `practitioner_credentials`, and a plpgsql body resolves its names at call time, so it could not be written before that table existed. **Auth landed with #83**: magic link only, so there is no password and no password reset; `@supabase/ssr` carries the session in cookies; `src/proxy.ts` refreshes it; and the `bluehex_admin` claim is read by the application. What that unblocks is that every policy in the spec is written against `auth.uid()`, and `auth.uid()` now resolves for a request the app makes on a signed-in person's behalf. Self-service writes through one as of #14, in two halves: the review queue at `/admin` (#122) and the profile editor at `/profile`, which reads the practitioner's own rows through `my_profile()` and `my_credentials()` and saves them with a Server Action. What is left of that ticket is the claim path for a curated profile, which is still an admin pasting an account id. The rest of this section is the contract for building those — treat it as binding, not as a description of current state. - **Target is [Supabase](https://supabase.com)** — Postgres, plus the auth that comes with it. Local development runs the Supabase CLI stack; deployed is a hosted Supabase diff --git a/src/app/profile/_lib/actions.ts b/src/app/profile/_lib/actions.ts new file mode 100644 index 0000000..da8e456 --- /dev/null +++ b/src/app/profile/_lib/actions.ts @@ -0,0 +1,299 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireAccount } from "@/lib/auth/session"; +import type { ProfileWrite } from "@/lib/profile-draft"; +import type { SaveResult } from "@/lib/profile-save"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import type { ProfileRow } from "./profile-mapping"; +import { + planCredentials, + planServices, + type SavedCredential, + type SavedService, +} from "./profile-plan"; + +/** + * Saving a profile. **The other half of the seam #71 left**, and the + * practitioner half of #14 — the admin half is `../../admin/_lib/actions`. + * + * ## Postgres decides, and this file cannot widen that + * + * `requireAccount` is presentation: it exists so a session that expired between + * the render and the click meets the sign-in form rather than a refusal nobody + * can read. What actually stops a practitioner writing `verified` or `status` + * is the column grants and the two guard triggers, checked by Postgres on every + * statement — see `AGENTS.md` under Database, and + * `docs/adr/0001-admins-are-a-postgres-role.md`. + * + * **Every column is named on the way in, and the two that identify the writer + * are read from the session rather than from the payload.** A Server Action's + * argument arrives from a browser and is untrusted: `user_id` is `viewer.id` + * and `contact_id` is the row this request just wrote, so neither can be + * supplied. Spreading `payload.profile` into an insert would work today — + * `practitioners_insert_own` and the grant list refuse anything extra — but it + * would fail with `42501` rather than being impossible, and the difference + * matters on the one table whose integrity is the product. + * + * There is no service role key anywhere in this path, and adding one would be a + * decision rather than a step. + * + * ## Creating a profile is two requests, and the order is forced + * + * `practitioners.contact_id` is `not null unique`, so the contact row is + * written first — "the enquiry button goes somewhere" is an invariant held by + * the direction of a foreign key rather than by anything here. The failure mode + * is deliberately the harmless one: if the second request fails, what is left + * behind is a contact row nobody references rather than a published profile + * nobody can reach. The spec accepts that and calls for an occasional sweep; + * this action does not try to be clever about it, because the alternatives — + * a transaction it cannot open over PostgREST, or a delete of the row it just + * wrote on an error path that may itself have failed — are both worse than a + * stray address. + * + * ## Failures come back as values + * + * Every path returns a `SaveResult` rather than throwing. A thrown error out of + * a Server Action is replaced with a generic message in a production build, and + * the specific one is what a practitioner needs: a `23505` on a credential they + * entered twice and a `23514` from the services cap are both things they can + * act on, and "something went wrong" is not. + */ + +/* The practitioner-writable set on `practitioners`, exactly as the grant lists + it. `user_id` and `contact_id` are in the insert grant and absent from the + update one — a profile cannot change hands and cannot be repointed at another + contact row — so they are added by `create` alone and never by `save`. */ +function profileColumns(payload: ProfileWrite) { + return { + name: payload.profile.name, + headline: payload.profile.headline, + location: payload.profile.location, + country_code: payload.profile.country_code, + bio: payload.profile.bio, + focus: payload.profile.focus, + availability: payload.profile.availability, + website_url: payload.profile.website_url, + github_url: payload.profile.github_url, + linkedin_url: payload.profile.linkedin_url, + booking_url: payload.profile.booking_url, + }; +} + +function contactColumns(payload: ProfileWrite) { + return { + contact_email: payload.contact.contact_email, + contact_phone: payload.contact.contact_phone, + contact_note: payload.contact.contact_note, + }; +} + +/** A refusal and never a success, so a caller composing a message onto one does + not have to narrow the union first. */ +type Refusal = Extract; + +/** + * What went wrong, in the words Postgres used, with the repair the schema put + * in the hint. + * + * `practitioner_services_cap` raises with the count in the message and + * `credentials_guard` says nothing a practitioner has to act on, so this is + * mostly a passthrough — but a constraint name on its own ("23505: + * practitioner_credentials_practitioner_id_catalogue_id_key") is not a + * sentence, so the two that are reachable through this form are translated. + */ +function refusal(error: { code?: string; message: string; hint?: string | null }): Refusal { + if (error.code === "23505") { + return { + ok: false, + message: + "One credential is listed twice. Each Claude credential goes on your profile once — " + + "remove the duplicate and save again. Nothing was changed.", + }; + } + + return { + ok: false, + message: error.hint ? `${error.message} — ${error.hint}` : error.message, + }; +} + +/** + * Purge what a save changed. + * + * `/profile` always, so the editor re-reads the rows Postgres now holds rather + * than the ones this render started with. The public pages only when the + * profile is approved: a pending profile is on no public page, so revalidating + * `/` would evict the whole directory on every save by every practitioner + * waiting in the queue. + * + * The same daily-clock caveat as the admin actions applies — `revalidatePath` + * is the minimal thing that works today, and the tag-based contract + * `src/app/page.tsx` names is #117. + */ +function purge(profile: { handle: string; status: ProfileRow["status"] }) { + revalidatePath("/profile"); + + if (profile.status === "approved") { + revalidatePath("/"); + revalidatePath(`/p/${profile.handle}`); + } +} + +export async function saveProfileAction(payload: ProfileWrite): Promise { + const viewer = await requireAccount("/profile"); + const supabase = await createServerSupabaseClient(); + + const { data: profiles, error: read } = await supabase.rpc("my_profile"); + if (read) return refusal(read); + + const existing = (profiles as unknown as ProfileRow[])[0] ?? null; + + /* One `id` and `handle` from here on, whichever branch produced them. The + handle is Postgres's — `new_profile_handle()` generates it as a column + default — so a create has to read it back rather than guess it. */ + let profile: { id: string; handle: string; status: ProfileRow["status"] }; + + if (!existing) { + const { data: contact, error: contactError } = await supabase + .from("practitioner_contacts") + .insert(contactColumns(payload)) + .select("id") + .single(); + if (contactError) return refusal(contactError); + + const { data: created, error: profileError } = await supabase + .from("practitioners") + .insert({ ...profileColumns(payload), user_id: viewer.id, contact_id: contact.id }) + .select("id,handle,status") + .single(); + + /* The orphaned contact row this leaves is the accepted cost above, and the + message says the retry is safe rather than leaving somebody to wonder + whether pressing Save again doubles something. It does write a second + contact row; that is the harmless direction, and the sweep is #52. */ + if (profileError) { + const refused = refusal(profileError); + return { + ok: false, + message: `${refused.message} Nothing was published, and it is safe to try again.`, + }; + } + + profile = created; + } else { + const { error: contactError } = await supabase + .from("practitioner_contacts") + .update(contactColumns(payload)) + .eq("id", existing.contact_id); + if (contactError) return refusal(contactError); + + const { data: updated, error: profileError } = await supabase + .from("practitioners") + .update(profileColumns(payload)) + .eq("id", existing.id) + .select("id,handle,status") + .single(); + if (profileError) return refusal(profileError); + + profile = updated; + } + + const children = await saveChildren(supabase, profile.id, payload); + if (!children.ok) { + /* The profile itself is saved by here, so this is not a failed save and + must not be reported as one — that would send somebody back to press a + button over a change Postgres has already committed. It is also not a + success: the credentials are what the badge attests to, and a profile + that quietly kept last week's list is the one lie this form cannot + afford. So it says which half landed. */ + purge(profile); + return { + ok: false, + message: `Your details were saved. Your credentials and services were not: ${children.message}`, + }; + } + + purge(profile); + return { ok: true }; +} + +/** + * The two child tables, reconciled to what the form submitted. + * + * Split out because it is the same work on both branches above: a create has no + * saved rows and an edit has some, and `./profile-plan` produces the same three + * piles either way. + * + * **Deletes go first.** Both tables have a reason: `practitioner_services_cap` + * counts rows and refuses the fourth, so swapping one service for another + * inserts into a full table unless the removal has already happened, and + * `unique (practitioner_id, catalogue_id)` does the same to a credential moved + * from one row to another. Ordering the piles is cheaper than teaching either + * constraint about intent. + */ +async function saveChildren( + supabase: Awaited>, + practitionerId: string, + payload: ProfileWrite, +): Promise { + const [saved, savedServices, catalogue] = await Promise.all([ + supabase.rpc("my_credentials"), + supabase.from("practitioner_services").select("id,catalogue_id,label").eq("practitioner_id", practitionerId), + supabase.from("service_catalogue").select("id,label"), + ]); + + const lookupFailure = saved.error ?? savedServices.error ?? catalogue.error; + if (lookupFailure) return refusal(lookupFailure); + + /* `my_credentials()` returns every credential the caller owns, which for one + account is one profile's worth — `practitioners.user_id` is unique. Filtered + anyway, because that is a fact about another table and this function should + not depend on it. */ + const savedCredentials = (saved.data as unknown as (SavedCredential & { practitioner_id: string })[]) + .filter((row) => row.practitioner_id === practitionerId); + + const credentials = planCredentials(savedCredentials, payload.credentials); + const services = planServices( + (savedServices.data ?? []) as SavedService[], + payload.services, + new Map((catalogue.data ?? []).map((entry) => [entry.label, entry.id])), + ); + + if (credentials.remove.length > 0) { + const { error } = await supabase + .from("practitioner_credentials") + .delete() + .in("id", credentials.remove); + if (error) return refusal(error); + } + + if (services.remove.length > 0) { + const { error } = await supabase + .from("practitioner_services") + .delete() + .in("id", services.remove); + if (error) return refusal(error); + } + + for (const { id, row } of credentials.update) { + const { error } = await supabase.from("practitioner_credentials").update(row).eq("id", id); + if (error) return refusal(error); + } + + if (credentials.insert.length > 0) { + const { error } = await supabase + .from("practitioner_credentials") + .insert(credentials.insert.map((row) => ({ ...row, practitioner_id: practitionerId }))); + if (error) return refusal(error); + } + + if (services.insert.length > 0) { + const { error } = await supabase + .from("practitioner_services") + .insert(services.insert.map((id) => ({ practitioner_id: practitionerId, catalogue_id: id }))); + if (error) return refusal(error); + } + + return { ok: true }; +} diff --git a/src/app/profile/_lib/profile-mapping.test.ts b/src/app/profile/_lib/profile-mapping.test.ts new file mode 100644 index 0000000..7aa3b7c --- /dev/null +++ b/src/app/profile/_lib/profile-mapping.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from "vitest"; + +import { emptyDraft } from "@/lib/profile-draft"; +import { + blankProfile, + toOwnProfile, + type ContactRow, + type CredentialRow, + type ProfileRow, + type ServiceRow, +} from "./profile-mapping"; + +/** + * The rows-to-draft half of the editor's read, asserted without a stack. + * + * `profile-read.ts` names the columns and makes the requests; every rule about + * what an absent value becomes, what order things come back in and what a + * credential's identity is lives here. Same split, and for the same reason, as + * `@/lib/directory` against `@/lib/directory-mapping`. + */ + +function profileRow(over: Partial = {}): ProfileRow { + return { + id: "p1", + handle: "abcd1234", + contact_id: "c1", + name: "Mara Ellison", + headline: null, + location: null, + country_code: null, + bio: null, + focus: null, + availability: null, + website_url: null, + github_url: null, + linkedin_url: null, + booking_url: null, + status: "pending", + ...over, + }; +} + +const contact: ContactRow = { + contact_email: "mara@example.invalid", + contact_phone: null, + contact_note: null, +}; + +function credential(over: Partial = {}): CredentialRow { + return { + id: "cr1", + catalogue_id: "cat1", + earned_at: "2026-01-22", + evidence_url: null, + evidence_public: false, + verified: false, + ...over, + }; +} + +function read(over: Partial[0]> = {}) { + return toOwnProfile({ + profile: profileRow(), + contact, + credentials: [], + services: [], + reviewNote: null, + ...over, + }); +} + +describe("blankProfile", () => { + it("prefills the contact address and nothing else", () => { + const own = blankProfile("mara@example.invalid"); + + expect(own.profile).toBeNull(); + expect(own.draft).toEqual({ ...emptyDraft(), contactEmail: "mara@example.invalid" }); + }); + + /* An account with no address on it is not a state anything mints — magic link + is the only provider and the address is the identity — but `Viewer.email` is + nullable, and a form is not the place to find out. */ + it("leaves the address blank when the account has none", () => { + expect(blankProfile(null).draft).toEqual(emptyDraft()); + }); + + it("is pending, unverified and unnoted", () => { + expect(blankProfile(null).controlled).toEqual({ + status: "pending", + verified: {}, + reviewNote: null, + }); + }); +}); + +describe("toOwnProfile", () => { + /* The mapping the write reverses. `""` is what a control holds for "not + saying" and `null` is what the column holds; a round trip that turned one + into the other would leave a table filtering wrongly for the rest of its + life — see `toWritePayload`, which is the other half. */ + it("reads every absent column back as an empty control", () => { + const { draft } = read(); + + expect(draft.headline).toBe(""); + expect(draft.countryCode).toBe(""); + expect(draft.bio).toBe(""); + expect(draft.availability).toBe(""); + expect(draft.websiteUrl).toBe(""); + expect(draft.contactPhone).toBe(""); + expect(draft.focus).toEqual([]); + }); + + it("carries the values that are there", () => { + const { draft } = read({ + profile: profileRow({ + headline: "Staff engineer, agent platforms", + country_code: "AU", + focus: ["Agents", "Evals"], + website_url: "https://example.invalid/mara", + }), + contact: { ...contact, contact_note: "Weekday mornings." }, + }); + + expect(draft.headline).toBe("Staff engineer, agent platforms"); + expect(draft.countryCode).toBe("AU"); + expect(draft.focus).toEqual(["Agents", "Evals"]); + expect(draft.websiteUrl).toBe("https://example.invalid/mara"); + expect(draft.contactNote).toBe("Weekday mornings."); + }); + + /* The key pairs a draft credential with its entry in `controlled.verified`, + and for a saved row the only identity that survives a reload is the primary + key. Getting this wrong shows a check against the wrong credential, which is + the one thing on this form that is Bluehex's word rather than the + practitioner's. */ + it("keys a credential on its row id, and its check with it", () => { + const { draft, controlled } = read({ + credentials: [ + credential({ id: "cr1", verified: true }), + credential({ id: "cr2", catalogue_id: "cat2", earned_at: "2026-06-04" }), + ], + }); + + expect(draft.credentials.map((row) => row.key)).toEqual(["cr2", "cr1"]); + expect(controlled.verified).toEqual({ cr1: true, cr2: false }); + }); + + it("orders credentials newest first", () => { + const { draft } = read({ + credentials: [ + credential({ id: "old", earned_at: "2025-03-01" }), + credential({ id: "new", catalogue_id: "cat2", earned_at: "2026-06-04" }), + credential({ id: "mid", catalogue_id: "cat3", earned_at: "2026-01-22" }), + ], + }); + + expect(draft.credentials.map((row) => row.key)).toEqual(["new", "mid", "old"]); + }); + + /* `verified` is Bluehex's and travels in `controlled`, never in the draft. + There is no field on `DraftCredential` for it to land in, and this asserts + that the read does not invent one by another name. */ + it("keeps the raw evidence link in the draft and the check out of it", () => { + const { draft } = read({ + credentials: [ + credential({ evidence_url: "https://example.invalid/cert", evidence_public: true, verified: true }), + ], + }); + + expect(draft.credentials[0]).toEqual({ + key: "cr1", + catalogueId: "cat1", + earnedAt: "2026-01-22", + evidenceUrl: "https://example.invalid/cert", + evidencePublic: true, + }); + }); + + describe("services", () => { + function serviceRow(over: Partial = {}): ServiceRow { + return { catalogue_id: "s1", label: null, service_catalogue: { label: "Code review" }, ...over }; + } + + it("reads a catalogue row as its chip", () => { + expect(read({ services: [serviceRow()] }).draft.services).toEqual(["Code review"]); + }); + + /* A free-text row is an admin's, written during curated intake, and the form + has no control that could render it. It is dropped from the draft and left + alone by the write — see `planServices`, which is what makes dropping it + non-destructive rather than a deletion one save later. */ + it("drops a label outside the closed vocabulary", () => { + const rows = [ + serviceRow(), + serviceRow({ catalogue_id: null, label: "Fractional CTO", service_catalogue: null }), + ]; + + expect(read({ services: rows }).draft.services).toEqual(["Code review"]); + }); + + it("does not render one service as two chips", () => { + const rows = [serviceRow(), serviceRow({ catalogue_id: "s2" })]; + + expect(read({ services: rows }).draft.services).toEqual(["Code review"]); + }); + }); + + it("carries the status and the review note through untouched", () => { + const { controlled } = read({ + profile: profileRow({ status: "rejected" }), + reviewNote: "The certificate link 404s.", + }); + + expect(controlled.status).toBe("rejected"); + expect(controlled.reviewNote).toBe("The certificate link 404s."); + }); +}); diff --git a/src/app/profile/_lib/profile-mapping.ts b/src/app/profile/_lib/profile-mapping.ts new file mode 100644 index 0000000..cbdb578 --- /dev/null +++ b/src/app/profile/_lib/profile-mapping.ts @@ -0,0 +1,196 @@ +import { services as vocabulary, type Service } from "@/lib/practitioners"; +import { emptyDraft, type BluehexControlled, type ProfileDraft } from "@/lib/profile-draft"; + +/** + * Postgres rows to the editor's draft. **The pure half of the read**, split + * from `./profile-read` the same way `@/lib/directory-mapping` is split from + * `@/lib/directory`: the shapes below are what the query returns, and every + * rule about nulls, ordering and identity is asserted without a stack. + * + * ## The two mappings that are not obvious + * + * **`null → ""`, in every direction the write does not go.** The draft is the + * form's model and a control has no null: `country_code` unset is `""` in a + * `