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..90cbd1e --- /dev/null +++ b/src/app/profile/_lib/actions.ts @@ -0,0 +1,372 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireAccount } from "@/lib/auth/session"; +import { toWritePayload, type ProfileDraft, type ProfileWrite } from "@/lib/profile-draft"; +import type { SaveResult } from "@/lib/profile-save"; +import { validateDraft } from "@/lib/profile-validation"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import type { ProfileRow } from "./profile-mapping"; +import { + planCredentials, + planServices, + type SavedCredential, + type SavedService, + type ServiceCatalogueEntry, +} 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. + * + * **It takes a `ProfileDraft` and maps it here, rather than taking the mapped + * `ProfileWrite` from the browser.** #125 found what the earlier signature + * cost: naming the columns defends against an *extra* one and says nothing + * about the values in the ones it names, and Postgres refuses very little of + * what is left — `name` and `contact_email` are `not null` and both accept + * `''`, which is how a crafted payload emptied an approved profile and left it + * published with no address to reach the practitioner at. `validateDraft` is + * the answer and it is defined over the draft, so the draft is what has to + * arrive. Mapping on this side is the same function the form used to call, and + * moving it here means the `"" → null` and `"" → drop this row` rules are the + * server's rather than something the client is trusted to have done. + * + * The database should say this too — `check (length(btrim(contact_email)) > 0)` + * is the durable half, and a form is not a constraint. That is a migration and + * it is #127. + * + * ## 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") { + /* On the constraint name rather than on the code, because three of them are + reachable from this one action and they mean different things to the + person reading. Falling through names the constraint, which is not a + sentence but is better than the wrong sentence. */ + if (error.message.includes("practitioner_credentials_")) { + 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.", + }; + } + + if (error.message.includes("practitioners_user_id_key")) { + return { + ok: false, + message: + "You already have a profile. This page was opened before it existed — reload it and " + + "your profile will be there to edit. 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(draft: ProfileDraft): Promise { + const viewer = await requireAccount("/profile"); + + /* Before anything is written, and before the client is trusted to have run + the same check. The first message rather than all of them: the form shows + every error against its own field and takes somebody to it, so this is the + backstop for a payload that did not come from the form. */ + const problems = validateDraft(draft); + if (problems.length > 0) return { ok: false, message: problems[0].message }; + + const payload = toWritePayload(draft); + 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, + /* **Which half landed is read off what actually ran**, not assumed. There + is no transaction across PostgREST — the deletes are their own + statements and commit before the inserts are attempted — so a save that + fails part way through has already changed the rows it got to. Saying + "your credentials were not saved" over a credential that has just been + deleted is the one lie this form cannot afford, and it was what this + message said before #125. Making it atomic — one `security definer` + RPC, one transaction — is #128; this is the honest report until then. */ + message: children.applied + ? "Your details were saved, and your credentials and services were changed only in " + + `part before this failed: ${children.message} Reload the page to see what Bluehex ` + + "now holds before you edit them again." + : `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. + */ +type ChildOutcome = { ok: true } | { ok: false; message: string; applied: boolean }; + +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"), + ]); + + /* Nothing has been written when a lookup fails, which is the one place in + this function `applied: false` is a fact rather than a claim. */ + const lookupFailure = saved.error ?? savedServices.error ?? catalogue.error; + if (lookupFailure) return { ...refusal(lookupFailure), applied: false }; + + /* `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, + (catalogue.data ?? []) as ServiceCatalogueEntry[], + ); + + /* Set by the first statement that commits, and read by the caller to decide + what to tell the practitioner. It is deliberately not a count of what + landed: this function knows that *something* did, and anything finer would + be a second description of the same rows for somebody to keep in step. */ + let applied = false; + const failed = (error: { code?: string; message: string; hint?: string | null }): ChildOutcome => ({ + ...refusal(error), + applied, + }); + + if (credentials.remove.length > 0) { + const { error } = await supabase + .from("practitioner_credentials") + .delete() + .in("id", credentials.remove); + if (error) return failed(error); + applied = true; + } + + if (services.remove.length > 0) { + const { error } = await supabase + .from("practitioner_services") + .delete() + .in("id", services.remove); + if (error) return failed(error); + applied = true; + } + + for (const { id, row } of credentials.update) { + const { error } = await supabase.from("practitioner_credentials").update(row).eq("id", id); + if (error) return failed(error); + applied = true; + } + + if (credentials.insert.length > 0) { + const { error } = await supabase + .from("practitioner_credentials") + .insert(credentials.insert.map((row) => ({ ...row, practitioner_id: practitionerId }))); + if (error) return failed(error); + applied = true; + } + + 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 failed(error); + applied = true; + } + + 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..ffde3e1 --- /dev/null +++ b/src/app/profile/_lib/profile-mapping.ts @@ -0,0 +1,187 @@ +import { isService, 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 + * `