diff --git a/app/(control)/admin/partners/[id]/page.tsx b/app/(control)/admin/partners/[id]/page.tsx index ef0b2df..f6b4bf8 100644 --- a/app/(control)/admin/partners/[id]/page.tsx +++ b/app/(control)/admin/partners/[id]/page.tsx @@ -17,6 +17,10 @@ export default async function AdminPartnerDetailPage({ await requireAdmin(); const locale = await controlLocale(); const t = await getTranslations({ locale, namespace: "control.admin.partnerDetail" }); + // The partner-facing namespace, reused here on purpose: the founder should read + // the same words for these fields that the partner does, or the two pages drift + // into describing the same record differently (§11). + const tb = await getTranslations({ locale, namespace: "control.brand" }); const { id } = await params; const partner = await db.user.findFirst({ where: { id, role: "PARTNER" }, @@ -29,11 +33,27 @@ export default async function AdminPartnerDetailPage({ // surfaced rather than acted on — nothing auto-revokes (lib/base-domain- // verification.ts). This is where "is that still theirs?" gets asked. baseDomains: { orderBy: [{ verifiedAt: "desc" }, { createdAt: "desc" }] }, + // Read-only here (§11). The founder needs to see what a partner would be + // published AS — and, more to the point, whether they have ASKED to be + // published — before the owner decision in §11e is taken. Nothing on this + // page writes it: the record belongs to the partner. + brand: true, }, }); if (!partner) notFound(); const balance = partner.commissions.reduce((s, c) => s + c.amountCents, 0); + // Joined once each so an all-empty line renders nothing at all rather than a row + // of separators. + const contact = [partner.brand?.email, partner.brand?.phone].filter(Boolean).join(" · "); + const address = [ + partner.brand?.addressLine1, + partner.brand?.postalCode, + partner.brand?.city, + partner.brand?.countryCode, + ] + .filter(Boolean) + .join(" · "); // One clock for the whole render, so two domains never disagree about staleness. const now = new Date(); @@ -97,6 +117,38 @@ export default async function AdminPartnerDetailPage({ +
+

{tb("adminTitle")}

+ {partner.brand ? ( +
+ {partner.brand.displayName} + {partner.brand.tagline && ( + {partner.brand.tagline} + )} + {partner.brand.websiteUrl && ( + // `rel="noopener noreferrer"` because this is a partner-supplied + // address opened from an admin page; the https-only check at the write + // schema is the other half (lib/partner-brand.ts). + + {partner.brand.websiteUrl} + + )} + {contact && {contact}} + {address.length > 0 && {address}} + + {tb(partner.brand.publishToTenants ? "adminPublishAsked" : "adminPublishOff")} + +
+ ) : ( +

{tb("adminEmpty")}

+ )} +
+

{t("ledger")}

diff --git a/app/(control)/dashboard/brand/page.tsx b/app/(control)/dashboard/brand/page.tsx new file mode 100644 index 0000000..40f5b82 --- /dev/null +++ b/app/(control)/dashboard/brand/page.tsx @@ -0,0 +1,71 @@ +import { getTranslations } from "next-intl/server"; +import { requirePartner } from "@/lib/rbac"; +import { controlLocale } from "@/lib/control-locale"; +import { db } from "@/lib/db"; +import { prefillFromBillingIdentity } from "@/lib/partner-brand"; +import PartnerBrandForm from "@/components/control/PartnerBrandForm"; + +/** + * `/dashboard/brand` — the partner's own public details (SOFRA-PARTNER-PLAN §11). + * + * `requirePartner()`, not `requirePartnerOrOwner()`, for the reason + * `/dashboard/domains` states: a direct restaurant owner has one tenant and it is + * their own, so there is no third party for them to be credited as. + * + * Reads are scoped by `partner.id` on both queries — a partner can only ever load + * their own brand and their own billing identity. + * + * Never cached: it renders a row the same request may have just written, and the + * row is per-user. + */ +export const dynamic = "force-dynamic"; + +export default async function PartnerBrandPage() { + const partner = await requirePartner(); + const locale = await controlLocale(); + const t = await getTranslations({ locale, namespace: "control.brand" }); + + const brand = await db.partnerBrand.findUnique({ where: { partnerId: partner.id } }); + + // Only consulted when there is no brand yet, and only for a NAME. Looked up by + // `userId` — the simple link, which is the right one here: this page is about a + // PARTY, not about one plan, so the plan-scoped `resolveIdentityForPlan` would be + // answering a question nobody asked. Nothing from this record is stored by + // loading the page; the partner sees the value in an editable field and saves it + // themselves (lib/partner-brand.ts explains why only this one field crosses). + // Read whether or not there is a brand already, because it now answers TWO + // questions and only the first is about prefilling. The second is D-B1a: a + // display name that IS the legal name will not be published, so the form has to + // know the legal name to be able to say so — and a partner whose record has no + // TRADE name is the one who needs telling before they type, since the only other + // name we hold is their own. Nothing from this record is stored by loading the + // page, and none of it is rendered: `legalName` reaches the client as a + // comparison input, which is a value the partner typed into their own billing + // record and is being shown their own copy of. + const identity = await db.billingIdentity.findUnique({ + where: { userId: partner.id }, + select: { legalName: true, tradeName: true }, + }); + const prefill = brand ? null : prefillFromBillingIdentity(identity); + + return ( +
+
+

{t("title")}

+

{t("intro")}

+

{t("privacyNote")}

+
+ +
+ {!brand && prefill && ( +

{t("prefillNote")}

+ )} + +
+
+ ); +} diff --git a/app/(control)/dashboard/layout.tsx b/app/(control)/dashboard/layout.tsx index c92b07f..0ab1396 100644 --- a/app/(control)/dashboard/layout.tsx +++ b/app/(control)/dashboard/layout.tsx @@ -48,6 +48,11 @@ export default async function DashboardLayout({ children }: { children: React.Re // let them claim their FIRST zone, so hiding it until they have one would hide // the only way to get one (SOFRA-PARTNER-FLEXIBILITY-PLAN D1). { href: "/dashboard/domains", label: t("nav.domains") }, + // Always present too, and for the same documented reason: the page's job is to + // let a partner enter their public details for the FIRST time, so gating the + // link on already having them would hide the only way to get them + // (SOFRA-PARTNER-PLAN §11). + { href: "/dashboard/brand", label: t("nav.brand") }, ]; if (hasBilling) { nav.push({ href: "/dashboard/billing", label: t("nav.plan") }); diff --git a/components/control/PartnerBrandForm.tsx b/components/control/PartnerBrandForm.tsx new file mode 100644 index 0000000..ef64625 --- /dev/null +++ b/components/control/PartnerBrandForm.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { useTranslations } from "next-intl"; +import { + savePartnerBrandAction, + type PartnerBrandState, +} from "@/lib/actions/partner-brand-actions"; +import { isLegalNameEcho } from "@/lib/partner-brand"; +import ActionError from "./ActionError"; + +/** What the page hands in — either the stored row or, for a partner who has none + * yet, a prefill carrying nothing but a display name. */ +export type BrandDefaults = { + displayName: string; + tagline?: string | null; + websiteUrl?: string | null; + email?: string | null; + phone?: string | null; + addressLine1?: string | null; + postalCode?: string | null; + city?: string | null; + countryCode?: string | null; + publishToTenants?: boolean; +}; + +type TextField = Exclude; + +/** + * A partner's PUBLIC details (SOFRA-PARTNER-PLAN §11). + * + * Bound directly to the server action so it also works as a plain form POST with + * no JavaScript (CLAUDE.md §3). + * + * Everything here is typed by hand, including the address, and that is the + * design rather than an oversight: the control plane already holds an address for + * this partner, in `BillingIdentity`, and for a sole trader it is their home. + * Only `displayName` may be prefilled, from the TRADE name (lib/partner-brand.ts). + */ +export default function PartnerBrandForm({ + defaults, + legalName, + hasTradeName, +}: Readonly<{ defaults?: BrandDefaults; legalName?: string | null; hasTradeName?: boolean }>) { + const t = useTranslations("control.brand"); + const [state, action, pending] = useActionState( + savePartnerBrandAction, + {}, + ); + + // Watched, so the partner is told BEFORE they save rather than after — and the + // same predicate the publish choke point uses (lib/partner-brand.ts), so the UI + // cannot promise something `renderableBrand` would then refuse. Saving is still + // allowed: it is their record, and refusing the write would leave them with no way + // to record their own name. What is refused is PUBLISHING it, and the note says so + // rather than the form accepting it silently and dropping it later (D-B1a). + const [displayName, setDisplayName] = useState(defaults?.displayName ?? ""); + const echoesLegalName = isLegalNameEcho(displayName, legalName); + + const field = ( + name: TextField, + opts: { required?: boolean; type?: string; maxLength?: number } = {}, + ) => ( + + ); + + return ( +
+ {/* Said once, up front, to the partner it applies to: a sole trader whose + billing record has no trade name has nothing we could publish, because the + only other name we hold is their own (§11b). Better here, where they are + about to type, than as a refusal after they save. */} + {legalName && !hasTradeName && ( +

+ {t("noTradeNameNote")} +

+ )} + {field("displayName", { required: true, maxLength: 80 })} + {/* `` rather than `role="status"`: same live region, and the native + element is announced by assistive tech that does not implement the ARIA + role (S6819). It is a live region on purpose — the partner types and the + verdict changes under them, so it has to be spoken, not merely rendered. */} + {echoesLegalName && ( + + {t("legalNameNotPublished")} + + )} + {field("tagline", { maxLength: 120 })} + {field("websiteUrl", { type: "url", maxLength: 200 })} + {field("email", { type: "email" })} + {field("phone", { maxLength: 40 })} + {field("addressLine1")} + {field("postalCode", { maxLength: 20 })} + {field("city", { maxLength: 120 })} + {field("countryCode", { maxLength: 2 })} + + {/* DISABLED on purpose, and the note says why. Nothing consumes + `publishToTenants` yet (§11e is an open owner decision), and a switch + that a partner can flip while nothing changes is a lie told to the one + person entitled to decide this. The column, the action and + `renderableBrand()` are all here, so enabling it later is this one + attribute. A disabled checkbox is not submitted, which the action already + reads as false. */} +
+ +

{t("publishMeaning")}

+

{t("publishUnavailable")}

+
+ +
+ + {state.ok && ( + + {t("saved")} + + )} + +
+ + ); +} diff --git a/lib/actions/partner-brand-actions.ts b/lib/actions/partner-brand-actions.ts new file mode 100644 index 0000000..a10849f --- /dev/null +++ b/lib/actions/partner-brand-actions.ts @@ -0,0 +1,94 @@ +"use server"; + +// A partner records the public brand their clients' guests may be shown +// (SOFRA-PARTNER-PLAN §11). +// +// This slice STORES and EDITS. It publishes nothing: no tenant site reads +// `PartnerBrand`, and `publishToTenants` is an intent flag whose only future +// reader is `renderableBrand()` (§11e — the owner gate). The write path is built +// now so that the day publishing is switched on, the details it publishes are +// ones the partner typed knowing they were public — rather than the legal record, +// which is a person's own name and home address for a sole trader. + +import { revalidatePath } from "next/cache"; +import { requirePartner } from "@/lib/rbac"; +import { db } from "@/lib/db"; +import { audit } from "@/lib/audit"; +import { rateLimit } from "@/lib/rate-limit"; +import { checkboxOn, partnerBrandSchema } from "@/lib/partner-brand"; + +/** `error` is a message key in `control.errors` (rendered by ); + * Zod issue messages pass through raw, as elsewhere in this directory. */ +export type PartnerBrandState = { error?: string; ok?: boolean }; + +export async function savePartnerBrandAction( + _prev: PartnerBrandState, + formData: FormData, +): Promise { + // `requirePartner()`, not `requirePartnerOrOwner()`: a brand is what a RESELLER + // shows on the restaurants they sell. A direct restaurant OWNER has one tenant, + // which is their own — there is no third party for them to be credited as, so + // this surface has nothing to offer them and they are bounced to /dashboard. + const partner = await requirePartner(); + + // Per `user.id` rather than per IP, matching the sibling partner actions: the + // actor is authenticated, so their id has no NAT collisions and no spoofable + // proxy header behind it. + if (!rateLimit(`partner-brand:${partner.id}`, 30, 15 * 60 * 1000)) { + return { error: "tooManyAttempts" }; + } + + const parsed = partnerBrandSchema.safeParse({ + ...Object.fromEntries(formData), + // A checkbox is absent from FormData when unticked, so it can never be read + // straight off the payload as a boolean. Anything that is not "on"/"true" is + // off — the safe direction for a flag whose true value means "show this to + // the public". + publishToTenants: checkboxOn(formData.get("publishToTenants")), + }); + if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "invalidInput" }; + const input = parsed.data; + + // An emptied field must be CLEARED, not left alone. The schema normalises a + // blank input to `undefined` (there is no "" state to store), and Prisma reads + // `undefined` in an update as "do not touch this column" — so passing the + // parsed object straight through would make deleting a tagline impossible: the + // partner would save, be told it worked, and reload to find the old line still + // there. `null` is the write that means "gone". + const data = { + displayName: input.displayName, + tagline: input.tagline ?? null, + websiteUrl: input.websiteUrl ?? null, + email: input.email ?? null, + phone: input.phone ?? null, + addressLine1: input.addressLine1 ?? null, + postalCode: input.postalCode ?? null, + city: input.city ?? null, + countryCode: input.countryCode ?? null, + publishToTenants: input.publishToTenants, + }; + + // The key comes from the SESSION and is never read from the payload. A + // `partnerId` field in the FormData would be an IDOR: any logged-in partner + // could rewrite another's public details, which is both a takeover of their + // identity and — once publishing exists — a way to put text on someone else's + // restaurant pages. `partnerId` is written LAST so that no spread can shadow + // it, even if the schema were later loosened to pass unknown keys through. + const row = await db.partnerBrand.upsert({ + where: { partnerId: partner.id }, + create: { ...data, partnerId: partner.id }, + update: data, + }); + + // No brand FIELD is logged. They are contact details of a real company and, in + // the sole-trader case, of a real person (CLAUDE.md §5.8 — no PII in logs). What + // the audit row needs is that the record was written and whether the partner + // asked for it to be public; the values themselves are one `SELECT` away for + // anyone entitled to read them. + await audit(partner.id, "partner.brand.saved", "PartnerBrand", row.partnerId, { + publishToTenants: row.publishToTenants, + }); + + revalidatePath("/dashboard/brand"); + return { ok: true }; +} diff --git a/lib/partner-brand-lookup.ts b/lib/partner-brand-lookup.ts new file mode 100644 index 0000000..1a4fa93 --- /dev/null +++ b/lib/partner-brand-lookup.ts @@ -0,0 +1,54 @@ +// Which partner, if any, may be credited in a tenant's footer (SOFRA-PARTNER-PLAN +// §11e). The DB half of the feature, deliberately separate from the pure rules in +// lib/partner-brand.ts: this file only GATHERS the two records, and the decision +// stays behind `renderableBrand`, which is the one door. +// +// Out of the vitest coverage floor for the reason `vies.ts` is — it queries — while +// everything it can get wrong about publishing is decided in a module that is in. + +import { db } from "@/lib/db"; +import { renderableBrand, type RenderableBrand } from "@/lib/partner-brand"; + +/** + * The publishable brand of the partner who sold this tenant, or `undefined`. + * + * `undefined`, not `null`, because that is what `TenantProvisionInput.partnerBrand` + * wants: absence is the contract there, and an entry with no credit must be + * byte-identical to one generated before the field existed. + * + * The slug is matched two ways on purpose. `Client.tenantSlug` is set by an ADMIN + * *after* provisioning, so at the moment a registry PR is proposed it is usually + * still null; the reseller's PLAN, however, already names the slug. Looking only at + * `Client.tenantSlug` would therefore have credited nobody on exactly the path this + * feature exists for, and the failure would have been silent — an entry with no + * partner keys reads the same as a partner who never opted in. + * + * Fails OPEN, to no credit: a database hiccup here must not stop a paid customer's + * tenant being proposed, and the cost of the safe direction is a missing footer line + * that a re-provision restores. The opposite direction is unrecoverable in kind — + * publishing a name because a lookup misfired. + */ +export async function tenantPartnerBrand(tenantSlug: string): Promise { + try { + const client = await db.client.findFirst({ + where: { OR: [{ tenantSlug }, { billing: { tenantSlug } }] }, + select: { partnerId: true }, + }); + if (!client) return undefined; + + // The legal name is fetched for ONE purpose — the D-B1a refusal — and never + // travels further: `renderableBrand` compares with it and does not return it. + const [brand, identity] = await Promise.all([ + db.partnerBrand.findUnique({ where: { partnerId: client.partnerId } }), + db.billingIdentity.findUnique({ + where: { userId: client.partnerId }, + select: { legalName: true }, + }), + ]); + return renderableBrand(brand, { legalName: identity?.legalName }) ?? undefined; + } catch (e) { + // Slug only: a brand is a company's, and for a sole trader a person's (§5.8). + console.error("tenantPartnerBrand failed; proposing without a credit", tenantSlug, e); + return undefined; + } +} diff --git a/lib/partner-brand-publish.ts b/lib/partner-brand-publish.ts new file mode 100644 index 0000000..9195908 --- /dev/null +++ b/lib/partner-brand-publish.ts @@ -0,0 +1,117 @@ +// A partner's PUBLIC brand — the PUBLISH half (SOFRA-PARTNER-PLAN §11e). +// +// Split out of lib/partner-brand.ts when the pair outgrew one file's LOC limit +// (CLAUDE.md §4), along the line the feature already had: that file says what a +// partner may STORE, this one says what may be SHOWN. Both stay pure — no `db`, +// no I/O, nothing that can read a session — and both stay in the coverage floor, +// listed explicitly in vitest.config.ts so the split moved no code out of scope. +// +// `@/lib/partner-brand` re-exports everything here, so it remains the one door +// callers need to know about. + +/** + * `https://` and nothing else. + * + * This value ends up in an `href` on a page served to the public, so the scheme + * is the security boundary, not a formatting preference. `javascript:` is script + * execution in the reader's page; `http:` is a downgrade we would be advertising + * on someone else's site. A bare host (`example.com`) is refused rather than + * silently prefixed: guessing a scheme for a partner is exactly how `http` gets + * published by accident, and the form asks for the full address. + * + * `URL` does the parsing, so no regex has to be right about hosts. + */ +export function isHttpsUrl(value: string): boolean { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} + +/** What is stored. Structural, for the same reason as `IdentityNames`. */ +export type StoredBrand = { + displayName: string; + tagline: string | null; + websiteUrl: string | null; + email: string | null; + phone: string | null; + addressLine1: string | null; + postalCode: string | null; + city: string | null; + countryCode: string | null; + publishToTenants: boolean; +}; + +/** + * Exactly what a public surface may render — an ATTRIBUTION, not a contact block + * (SOFRA-PARTNER-PLAN D-B1): a name and, when there is one, a link — *"Site by + * "*. The address, phone, email and tagline this used to carry are + * deliberately GONE. Dropping fields is the self-announcing direction (the + * assertions naming them go red); widening back to a contact block is the silent + * one and needs its own controls plus a `pii-inventory.md` row (D-B4) — a sole + * trader's business email and phone are still personal data. + * + * `websiteUrl` is ABSENT rather than null, so this is already the shape the + * registry generator consumes: a key it does not emit. + */ +export type RenderableBrand = { displayName: string; websiteUrl?: string }; + +/** trim · case-fold · collapse internal whitespace. Comparison only — nothing + * normalised here is ever stored or shown. */ +const normalizeName = (value: string): string => + value.trim().toLowerCase().replaceAll(/\s+/g, " "); + +/** + * Is this "brand" just the legal name typed again? (D-B1a) + * + * NORMALISED, because the trap is a PREFILLED value saved with a stray space or a + * different case: " mustafa VURAL " is the same person as "Mustafa Vural", and + * an exact-string check would publish them onto a restaurant's public page. + * + * Exported so the FORM can say so, in the partner's own language, before they + * save. One predicate, two readers — a second copy in the UI is the one that + * drifts, and it drifts towards permissive. + */ +export function isLegalNameEcho( + displayName: string | null | undefined, + legalName: string | null | undefined, +): boolean { + const brand = normalizeName(displayName ?? ""); + return brand.length > 0 && brand === normalizeName(legalName ?? ""); +} + +/** + * The ONLY way a brand reaches a public surface — and the only place it is + * refused. Returns null when: there is no brand · `publishToTenants` is false · + * `displayName` is blank · `displayName` IS the legal name. **A missing brand + * renders NOTHING; it never falls back.** + * + * A single choke point on purpose: the alternative — every future caller + * remembering the flag and the legal-name comparison — holds until the first + * caller that forgets, and that failure is silent, a name nobody consented to + * publish sitting in a stranger's footer with nothing going red. + * + * It takes the LEGAL name because D-B1a is a rule about the RELATIONSHIP between + * the two records, so the one function allowed to publish must know both. A named + * option, not a positional string: a caller that omits it disarms the echo rule + * visibly, instead of sliding a name into the wrong slot. And it RE-PROJECTS + * rather than spreading the row, so a column added to `PartnerBrand` later is not + * published by the mere fact of having been added. + */ +export function renderableBrand( + brand: StoredBrand | null | undefined, + opts: { legalName?: string | null } = {}, +): RenderableBrand | null { + if (!brand?.publishToTenants) return null; + const displayName = brand.displayName.trim(); + if (!displayName) return null; + if (isLegalNameEcho(displayName, opts.legalName)) return null; + // Re-checked though the write schema already refuses anything but https: + // defence in depth on the one field that becomes an `href` on a public page. A + // row predating the schema, or written by a future admin tool, must not publish + // a `javascript:` link — and dropping the LINK is the right refusal, since the + // name is still what the partner asked to show. + const websiteUrl = brand.websiteUrl?.trim(); + return { displayName, ...(websiteUrl && isHttpsUrl(websiteUrl) ? { websiteUrl } : {}) }; +} diff --git a/lib/partner-brand.ts b/lib/partner-brand.ts new file mode 100644 index 0000000..02c0297 --- /dev/null +++ b/lib/partner-brand.ts @@ -0,0 +1,126 @@ +// A partner's PUBLIC brand — the WRITE rules about it (SOFRA-PARTNER-PLAN §11). +// +// What may be SHOWN moved to lib/partner-brand-publish.ts when the pair outgrew +// one file's LOC limit (CLAUDE.md §4) and is re-exported at the foot of this +// file, so `@/lib/partner-brand` is still the only import path anyone needs. +// +// Pure by construction: no `db` import, no I/O, nothing that can read a session. +// Everything here is either "is this input acceptable" or "may this record be +// shown", and both have to be answerable — and unit-testable — without a +// database. +// +// The one idea the whole module exists to protect: **a public record and a legal +// record are different records.** `BillingIdentity` holds the name a member state +// registered and the address an invoice is posted to; for a sole trader those are +// a person and their home. This holds what a diner may be shown. Nothing copies +// the first into the second except `prefillFromBillingIdentity`, which carries one +// field and says why. + +import { z } from "zod"; +import { isAssignedCountryCode } from "@/lib/country-code"; +import { isHttpsUrl } from "@/lib/partner-brand-publish"; + +/** + * An empty form field is ABSENT, not the empty string. + * + * A browser posts every text input it renders, so an untouched optional field + * arrives as `""`. Stored verbatim that would make `tagline: ""` and `tagline: + * null` two spellings of the same state, and every future reader would have to + * know both — the one that forgot would render an empty line in a footer. + */ +const blankToUndefined = (value: unknown): unknown => + typeof value === "string" && value.trim() === "" ? undefined : value; + +const optionalText = (max: number) => + z.preprocess(blankToUndefined, z.string().trim().max(max).optional()); + +/** + * What a partner must supply to record a public brand. + * + * `displayName` is the only required field, and deliberately so: it is the only + * one that has to exist for the record to mean anything, and demanding an address + * or a phone number would push a partner into typing their private ones back in. + * + * `countryCode` is checked for MEMBERSHIP of ISO 3166-1 alpha-2 through the SAME + * helper the billing schema uses (lib/country-code.ts) — one list, not two. It + * decides nothing about tax here (it is only ever displayed), but a second, + * drifting copy of the country list is the kind of thing that later gets consulted + * by something that does. + */ +export const partnerBrandSchema = z.object({ + displayName: z.string().trim().min(1).max(80), + tagline: optionalText(120), + websiteUrl: z.preprocess( + blankToUndefined, + z + .string() + .trim() + .max(200) + .refine(isHttpsUrl, "Use a full address starting with https://") + .optional(), + ), + email: z.preprocess(blankToUndefined, z.string().trim().max(200).email().optional()), + phone: optionalText(40), + addressLine1: optionalText(200), + postalCode: optionalText(20), + city: optionalText(120), + countryCode: z.preprocess( + blankToUndefined, + z + .string() + .trim() + .toUpperCase() + .refine(isAssignedCountryCode, "2-letter ISO 3166-1 country code, e.g. CH") + .optional(), + ), + publishToTenants: z.boolean(), +}); + +export type PartnerBrandInput = z.infer; + +/** A checkbox is present-or-absent in FormData; anything else is off. Kept beside + * the schema so the form and the action cannot disagree about what "on" means. */ +export function checkboxOn(value: unknown): boolean { + return value === "on" || value === "true"; +} + +/** The subset of a billing identity a prefill may read. Structural rather than the + * Prisma type, so the tests need no generated client. */ +export type IdentityNames = { legalName: string; tradeName: string | null }; + +/** + * The one field that may be carried from the legal record into the public one. + * + * `tradeName ?? legalName` and NOTHING ELSE, as a saving of typing on the field + * that is least likely to be private — a trade name is chosen to be shown. It is + * still only a DEFAULT in an editable input; the partner sees it before it is + * stored and can replace it. + * + * The address, the registration number and the VAT number are deliberately not + * here. Copying them would be the exact mistake the two-model split exists to + * prevent: for a sole trader the billing address is a home address and + * `legalName` is a natural person, and a prefilled field is one that gets saved + * without being read. The partner types the rest by hand, knowing it is public, + * and that act of typing IS the consent. + * + * The `legalName` fallback is the one concession, and it is bounded: a partner + * with no trade name sees their own name in a visible, editable field labelled + * as the public one, not silently published. + */ +export function prefillFromBillingIdentity( + identity: IdentityNames | null | undefined, +): { displayName: string } | null { + const name = identity?.tradeName?.trim() || identity?.legalName?.trim(); + return name ? { displayName: name } : null; +} + +// The PUBLISH half, re-exported so `@/lib/partner-brand` stays the one import +// path for this feature. It lives in its own file only because the pair outgrew +// the 200-LOC limit (CLAUDE.md §4); `renderableBrand` is still the single door. +export { + isHttpsUrl, + isLegalNameEcho, + renderableBrand, + type RenderableBrand, + type StoredBrand, +} from "@/lib/partner-brand-publish"; diff --git a/lib/provisioning-pr-blocks.ts b/lib/provisioning-pr-blocks.ts new file mode 100644 index 0000000..ddf5440 --- /dev/null +++ b/lib/provisioning-pr-blocks.ts @@ -0,0 +1,127 @@ +// The CONDITIONAL sections of an ADR-012 provisioning PR body — the parts that +// appear only when the entry carries something the founder has to look at before +// merging (a withheld module, a partner's own zone, a partner credit). +// +// Split out of lib/provisioning-pr-body.ts when the pair outgrew one file's LOC +// limit (CLAUDE.md §4), the same split that file records having had from +// provisioning-registry.ts. Each function returns markdown LINES and an empty +// array when its condition does not hold, so the body composes them by spreading +// — a section that does not apply contributes nothing, not a blank heading. +// +// Pure: no GitHub API, no secrets, no env. + +import type { TenantProvisionInput } from "./provisioning-registry"; + +/** + * Bought but deliberately NOT in this entry. + * + * Named, not silent. The entry omits a module they PAID for, so the body has to say + * so where the founder is already reading — otherwise the checklist quietly + * contradicts the receipt, and the gap is discovered by a customer asking why card + * payment does not work. Empty for every tenant that bought nothing deferred. + */ +export function deferredSection( + slug: string, + granted: string[], + deferred: string[], +): string[] { + if (!deferred.length) return []; + return [ + "", + `### ⚠️ Bought but deliberately NOT in this entry: \`${deferred.join(", ")}\``, + "", + `They paid for \`${deferred.join(", ")}\` and they keep it — the plan, the price and the`, + "subscription are unchanged. It is out of **this** entry because `provision-tenant.sh`", + "refuses the pair `online-payments` without `stripe_account:`, and refuses it *before*", + "the database, the compose project or the image — so proposing both here would not give", + "them a restaurant lacking card payment, it would give them **no restaurant at all**.", + "", + "No account was supplied with this proposal. If you are the founder and you already", + "hold their `acct_` — runbook §2b has you create it *before* proposing, exactly so", + "this does not happen — the fix is one shot, not two: add both fields in **Files", + "changed** before merging and delete this section's premise. Otherwise the account", + "genuinely cannot exist yet, because only the restaurant can create it, through", + "Stripe's hosted onboarding, which cannot be pre-filled. In that case provision them", + "now on everything else, then, once they have finished Stripe and you have their id, open a", + "**second** registry PR that adds BOTH halves together — one without the other trips", + "the same guard. Only these two fields change; the rest of the entry stays as merged:", + "", + "```yaml", + ` ${slug}:`, + " stripe_account: acct_XXXXXXXXXXXX", + ` modules: [${[...granted, ...deferred].join(", ")}]`, + "```", + "", + `…then re-run provisioning (\`gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}\`)`, + "and restart the tenant so it picks up the Stripe env. Full recipe — account creation,", + "the KYC sitting, TWINT, the box env — workspace `docs/runbooks/signup-to-live-tenant.md`", + "**§2b**, which is written to be followed BEFORE this second PR.", + ]; +} + +/** + * A partner's own zone. + * + * The one thing that can go wrong with a partner-zone entry and cannot be fixed after + * the merge: the name has to RESOLVE before provisioning, because the certificate is + * issued per hostname over HTTP-01 and there is no way to pre-issue one. A tenant + * provisioned ahead of its A record sits without TLS, which looks exactly like a + * broken product to the restaurant that was just handed a link. + */ +export function baseDomainSection(baseDomain: string | undefined, domain: string): string[] { + if (!baseDomain) return []; + return [ + "", + `### ⚠️ Partner zone — \`base_domain: ${baseDomain}\`, so the wildcard does NOT cover it`, + "", + `\`\`\`bash\ndig +short ${domain} # must already answer with this box's IP\n\`\`\``, + "", + "The partner publishes that A record in their own zone (plan §D1a: per-client A record —", + "a delegated subzone is impossible, not merely worse). An empty answer means merging will", + "stand the tenant up and then fail to get a certificate, which are issued per hostname over", + "HTTP-01 and cannot be pre-issued: **wait for the record rather than merge and retry.** And", + "note `NEXT_PUBLIC_*` are baked per domain, so changing this domain later is a rebuild plus", + "a re-provision, not a registry edit.", + ]; +} + +/** + * A partner credit is about to become PUBLIC (SOFRA-PARTNER-PLAN §11e). + * + * This section exists because the registry PR is the founder's review checkpoint + * (ADR-012), and this is the moment someone should notice that a name and a link are + * about to appear on a restaurant's page — a page belonging to a third party. It is + * the last cheap place to say "no": after the merge the entry is provisioned, and + * un-publishing means another registry PR and another re-provision. + * + * It also names the field the RESTAURANT can be given (D-B2), because the founder is + * the only party who can add it on their behalf, and the entry deliberately does not + * carry it by default: absent means attribution is on. + */ +export function partnerSection( + slug: string, + brand: NonNullable, +): string[] { + return [ + "", + `### 👤 This entry credits a partner in the tenant's footer: \`${brand.displayName}\``, + "", + `The footer of every page on this restaurant's site will read *"Site by ${brand.displayName}"*${ + brand.websiteUrl ? `, linked to \`${brand.websiteUrl}\`` : " (no link — the partner recorded no website)" + }.`, + "It is here because that partner asked for it on `/dashboard/brand`, and only a name and", + "a URL cross: no address, no phone, and never the legal name — a sole trader's legal name", + "is a private individual's, which is why the control plane refuses to publish it at all.", + "", + "**Check the restaurant is content to be credited.** If they are not, add one line to this", + "entry before merging — the default is on, so the key only ever appears to turn it off:", + "", + "```yaml", + ` ${slug}:`, + " partner_attribution: false", + "```", + "", + "It is resolved during provisioning, so the tenant's env carries only what to display.", + "Changing it later is a registry edit plus a re-provision (a `restart` re-reads nothing).", + ]; +} diff --git a/lib/provisioning-pr-body.ts b/lib/provisioning-pr-body.ts index 6a0ac3f..bf832e1 100644 --- a/lib/provisioning-pr-body.ts +++ b/lib/provisioning-pr-body.ts @@ -8,6 +8,9 @@ // checklist against it. import { splitDeferredModules, tenantDomain, type TenantProvisionInput } from "./provisioning-registry"; +// The conditional sections live next door (LOC limit, CLAUDE.md §4). Each returns an +// empty array when it does not apply, so this file spreads them unconditionally. +import { baseDomainSection, deferredSection, partnerSection } from "./provisioning-pr-blocks"; // Close the quote, emit an escaped apostrophe, reopen: the only way to get a // literal ' inside a POSIX single-quoted argument. @@ -77,65 +80,6 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string { "two commands below are **required**, not a fallback. Still check the entry first:", ]; - // Named, not silent. The entry omits a module they PAID for, so the body has to say - // so where the founder is already reading — otherwise the checklist above quietly - // contradicts the receipt, and the gap is discovered by a customer asking why card - // payment does not work. Empty for every tenant that bought nothing deferred. - const deferredBlock = deferred.length - ? [ - "", - `### ⚠️ Bought but deliberately NOT in this entry: \`${deferred.join(", ")}\``, - "", - `They paid for \`${deferred.join(", ")}\` and they keep it — the plan, the price and the`, - "subscription are unchanged. It is out of **this** entry because `provision-tenant.sh`", - "refuses the pair `online-payments` without `stripe_account:`, and refuses it *before*", - "the database, the compose project or the image — so proposing both here would not give", - "them a restaurant lacking card payment, it would give them **no restaurant at all**.", - "", - "No account was supplied with this proposal. If you are the founder and you already", - "hold their `acct_` — runbook §2b has you create it *before* proposing, exactly so", - "this does not happen — the fix is one shot, not two: add both fields in **Files", - "changed** before merging and delete this section's premise. Otherwise the account", - "genuinely cannot exist yet, because only the restaurant can create it, through", - "Stripe's hosted onboarding, which cannot be pre-filled. In that case provision them", - "now on everything else, then, once they have finished Stripe and you have their id, open a", - "**second** registry PR that adds BOTH halves together — one without the other trips", - "the same guard. Only these two fields change; the rest of the entry stays as merged:", - "", - "```yaml", - ` ${slug}:`, - " stripe_account: acct_XXXXXXXXXXXX", - ` modules: [${[...granted, ...deferred].join(", ")}]`, - "```", - "", - `…then re-run provisioning (\`gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}\`)`, - "and restart the tenant so it picks up the Stripe env. Full recipe — account creation,", - "the KYC sitting, TWINT, the box env — workspace `docs/runbooks/signup-to-live-tenant.md`", - "**§2b**, which is written to be followed BEFORE this second PR.", - ] - : []; - - // The one thing that can go wrong with a partner-zone entry and cannot be fixed after - // the merge: the name has to RESOLVE before provisioning, because the certificate is - // issued per hostname over HTTP-01 and there is no way to pre-issue one. A tenant - // provisioned ahead of its A record sits without TLS, which looks exactly like a - // broken product to the restaurant that was just handed a link. - const baseDomainBlock = input.baseDomain - ? [ - "", - `### ⚠️ Partner zone — \`base_domain: ${input.baseDomain}\`, so the wildcard does NOT cover it`, - "", - `\`\`\`bash\ndig +short ${domain} # must already answer with this box's IP\n\`\`\``, - "", - "The partner publishes that A record in their own zone (plan §D1a: per-client A record —", - "a delegated subzone is impossible, not merely worse). An empty answer means merging will", - "stand the tenant up and then fail to get a certificate, which are issued per hostname over", - "HTTP-01 and cannot be pre-issued: **wait for the record rather than merge and retry.** And", - "note `NEXT_PUBLIC_*` are baked per domain, so changing this domain later is a rebuild plus", - "a re-provision, not a registry edit.", - ] - : []; - const after = chained ? [ "The chain provisions **first-time only**, and reports back on this PR when it is done —", @@ -159,6 +103,11 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string { }`, `- **box** \`${box}\` · status starts at \`provisioning\`${ input.baseDomain ? ` · **base_domain** \`${input.baseDomain}\` (a partner's own zone)` : "" + }${ + // In the summary as well as its own section below: this is the line a founder + // skims, and a name becoming public is the one thing in the diff that is about + // a third party rather than about the tenant. + input.partnerBrand ? ` · **partner credit** \`${input.partnerBrand.displayName}\` (public)` : "" }`, "", ...header, @@ -172,8 +121,11 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string { : `- [ ] **modules** \`${granted.join(", ")}\` match what they actually paid for — they are enforced at runtime now, so a missing id is a feature they bought and will not get`, tagCheck, `- [ ] **template** \`${input.template}\` and **currency** \`${input.currency}\` are right — the template is baked into the image at build time, so changing it later is a rebuild`, - ...deferredBlock, - ...baseDomainBlock, + ...deferredSection(slug, granted, deferred), + ...baseDomainSection(input.baseDomain, domain), + // Only when a publishable partner brand reached the entry — `renderableBrand` + // decided that, not this file. + ...(input.partnerBrand ? partnerSection(slug, input.partnerBrand) : []), "", ...after, "", diff --git a/lib/provisioning-registry.ts b/lib/provisioning-registry.ts index 7787f5d..f19a720 100644 --- a/lib/provisioning-registry.ts +++ b/lib/provisioning-registry.ts @@ -84,6 +84,19 @@ export interface TenantProvisionInput { /** The tenant's Stripe connected account (`acct_…`), when they already have one. * Absent on the self-serve path; present when the founder followed runbook §2b. */ stripeAccount?: string; + /** + * The reseller credit this tenant's footer may carry (§11e) — and it is typed as + * the OUTPUT of `renderableBrand`, not as a partner id or a brand row, on purpose. + * That choke point is where `publishToTenants` and the D-B1a legal-name refusal + * are decided, so a caller cannot reach this field without having passed them: + * the only way to obtain a value of this shape is to have been allowed one. + * + * Optional, and ABSENCE IS THE CONTRACT, exactly as `baseDomain` above: without it + * the generator emits precisely what it emitted before this field existed — no + * `partner_name:`, no `partner_url:` — which is every entry in the registry today. + * The proof is that every pre-existing test of this function passes unchanged. + */ + partnerBrand?: { displayName: string; websiteUrl?: string }; city?: string; /** Which box the tenant belongs on; provision-tenant.sh refuses a mismatch. */ box?: string; @@ -163,6 +176,17 @@ export function buildTenantRegistryEntry(input: TenantProvisionInput): { ...(stripeAccount ? { stripe_account: stripeAccount } : {}), // Only emit city when set — the registry field is optional. ...(input.city ? { city: input.city } : {}), + // The partner credit, emitted only when there is a publishable one. NOT + // `partner_attribution:` — absent means true (D-B2), so writing it on every + // entry would be a no-op line on all of them and a diff on all of them. It is + // the RESTAURANT's switch, hand-added on their behalf when they ask for it, and + // resolved in `provision-tenant.sh` so the tenant env carries one meaning only. + ...(input.partnerBrand + ? { + partner_name: input.partnerBrand.displayName, + ...(input.partnerBrand.websiteUrl ? { partner_url: input.partnerBrand.websiteUrl } : {}), + } + : {}), }, }; const block = stringify(entry) diff --git a/lib/provisioning.ts b/lib/provisioning.ts index 8348f58..16af099 100644 --- a/lib/provisioning.ts +++ b/lib/provisioning.ts @@ -7,6 +7,7 @@ // (invariant 2). import { buildProvisioningPrBody } from "@/lib/provisioning-pr-body"; +import { tenantPartnerBrand } from "@/lib/partner-brand-lookup"; import { buildTenantRegistryEntry, type TenantProvisionInput } from "@/lib/provisioning-registry"; const OWNER = "piwas-21"; @@ -82,11 +83,20 @@ export async function openProvisioningPr( throw new ProvisioningApiError(`registry already has a '${input.slug}' entry`); } + // The reseller credit for the tenant's footer (SOFRA-PARTNER-PLAN §11e), resolved + // HERE rather than accepted from a caller. It is DERIVED from the slug — nobody + // types it, and it is not the founder's to type — so filling it at the one place + // that writes a registry entry means no caller can forget it, no caller can inject + // one, and a third path added later inherits it. Whatever the input carried is + // deliberately overwritten: `renderableBrand` behind this lookup is the only thing + // allowed to decide that a name may be published (D-B1/D-B1a). + const partnerBrand = await tenantPartnerBrand(input.slug); + // `deferred` is returned to the caller rather than only rendered into the PR body: a // deferral means a customer is being BILLED for a module their tenant will not have // until a second registry PR lands, and a prose section in one PR is not a record // anyone can query later. The callers put it in the audit trail. - const { entry, deferred } = buildTenantRegistryEntry(input); + const { entry, deferred } = buildTenantRegistryEntry({ ...input, partnerBrand }); // trimEnd() (no regex) drops any trailing whitespace/newlines, then we re-add // exactly two — avoids the ReDoS-prone `\n*$`, and the blank line keeps the // new tenant from butting against the previous one's trailing comment, which @@ -132,7 +142,10 @@ export async function openProvisioningPr( title: `Provision tenant: ${input.slug}`, head: branch, base: BASE, - body: buildProvisioningPrBody(input), + // The SAME resolved credit the entry carries: a body that describes a partner + // the diff does not name (or omits one it does) is worse than no body — the + // founder ticks the checklist against it. + body: buildProvisioningPrBody({ ...input, partnerBrand }), }), }); return { prUrl: pr.html_url, deferred }; diff --git a/messages/ar.json b/messages/ar.json index 4144db0..6a85831 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -482,7 +482,8 @@ "invoices": "الفواتير", "icp": "ICP", "billingDetails": "بيانات الفوترة", - "domains": "النطاقات" + "domains": "النطاقات", + "brand": "العلامة" }, "groups": { "pipeline": "المسار", @@ -1233,6 +1234,35 @@ "sending": "جارٍ الإرسال…", "sent": "تم الإرسال — سنعود إليك." }, + "brand": { + "title": "بياناتك العامة", + "intro": "الاسم وبيانات الاتصال التي قد يراها ضيوف المطعم بوصفها الشركة التي تقف خلف موقعه. تكتبها هنا حتى لا يُنشر سوى ما تختاره أنت.", + "privacyNote": "هذه البيانات منفصلة عمداً عن بيانات الفوترة. يبقى عنوان الفاتورة ورقم السجل خاصين ولا يُعرضان لأحد غيرنا.", + "prefillNote": "ملأنا اسمك التجاري للبدء. لا يُنسخ أي شيء آخر من بيانات الفوترة — اكتب ما توافق على أن يراه الضيوف.", + "noTradeNameNote": "تتضمن بيانات الفوترة اسماً قانونياً دون اسم تجاري. لا يمكننا نشر اسم شخص على موقع مطعم، فاكتب من فضلك العلامة التجارية التي يجب أن يراها ضيوف عملائك.", + "legalNameNotPublished": "هذا هو اسمك القانوني، ولن يُعرض على موقع أي مطعم. تُحفظ بياناتك على أي حال — اكتب اسم علامة تجارية لتُنسب إليك المواقع.", + "fields": { + "displayName": "الاسم المعروض", + "tagline": "شعار قصير (اختياري)", + "websiteUrl": "الموقع الإلكتروني (https://…)", + "email": "البريد الإلكتروني للتواصل", + "phone": "الهاتف", + "addressLine1": "العنوان", + "postalCode": "الرمز البريدي", + "city": "المدينة", + "countryCode": "الدولة (حرفان)" + }, + "publishLabel": "اعرض هذه البيانات في تذييل كل موقع مطعم تبيعه.", + "publishMeaning": "سيراها ضيوف عملائك.", + "publishUnavailable": "غير متاح بعد — يبقى النشر معطّلاً إلى أن يفعّله مالك المنصة. تُحفظ بياناتك في كل الأحوال.", + "save": "حفظ", + "saving": "جارٍ الحفظ…", + "saved": "تم الحفظ.", + "adminTitle": "علامته التجارية العامة", + "adminEmpty": "لم تُدخل أي بيانات عامة.", + "adminPublishAsked": "طلب أن يظهر على مواقع مطاعمه.", + "adminPublishOff": "لم يطلب الظهور العلني." + }, "baseDomain": { "title": "نطاقاتك الخاصة", "intro": "ضَع المطاعم التي تبيعها تحت نطاق خاص بك — obresse.yourcompany.com بدلاً من obresse.sofrapiwas.com. أضِف النطاق هنا وأثبت ملكيته؛ بعدها يُعرض عليك مع كل عميل جديد.", diff --git a/messages/de.json b/messages/de.json index ab6fd5b..d447d0f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -482,7 +482,8 @@ "invoices": "Rechnungen", "icp": "ICP", "billingDetails": "Rechnungsdaten", - "domains": "Domains" + "domains": "Domains", + "brand": "Marke" }, "groups": { "pipeline": "Pipeline", @@ -1233,6 +1234,35 @@ "sending": "Senden…", "sent": "Gesendet — wir melden uns." }, + "brand": { + "title": "Ihre öffentlichen Angaben", + "intro": "Der Name und die Kontaktdaten, die Gäste eines Restaurants als das Unternehmen hinter der Website sehen könnten. Sie geben sie hier ein, damit nur das öffentlich wird, was Sie auswählen.", + "privacyNote": "Sie sind bewusst von Ihren Rechnungsdaten getrennt. Ihre Rechnungsadresse und Ihre Registernummer bleiben privat und werden niemandem außer uns gezeigt.", + "prefillNote": "Wir haben Ihren Handelsnamen als Anfang eingetragen. Sonst wird nichts aus Ihren Rechnungsdaten übernommen — bitte geben Sie ein, was Gäste sehen dürfen.", + "noTradeNameNote": "Ihre Rechnungsdaten enthalten einen amtlichen Namen, aber keinen Handelsnamen. Einen Personennamen dürfen wir nicht auf der Website eines Restaurants veröffentlichen — bitte geben Sie die Marke ein, die die Gäste Ihrer Kunden sehen sollen.", + "legalNameNotPublished": "Das ist Ihr amtlicher Name; er wird auf keiner Restaurant-Website angezeigt. Ihre Angaben werden trotzdem gespeichert — geben Sie einen Markennamen ein, um genannt zu werden.", + "fields": { + "displayName": "Anzeigename", + "tagline": "Slogan (optional)", + "websiteUrl": "Website (https://…)", + "email": "Kontakt-E-Mail", + "phone": "Telefon", + "addressLine1": "Adresse", + "postalCode": "Postleitzahl", + "city": "Stadt", + "countryCode": "Land (2 Buchstaben)" + }, + "publishLabel": "Diese Angaben in der Fußzeile jeder Restaurant-Website zeigen, die Sie weiterverkaufen.", + "publishMeaning": "Die Gäste Ihrer Kunden sehen sie.", + "publishUnavailable": "Noch nicht verfügbar — die Veröffentlichung bleibt aus, bis der Plattformbetreiber sie freigibt. Ihre Angaben werden in jedem Fall gespeichert.", + "save": "Speichern", + "saving": "Speichern…", + "saved": "Gespeichert.", + "adminTitle": "Ihre öffentliche Marke", + "adminEmpty": "Keine öffentlichen Angaben eingegeben.", + "adminPublishAsked": "Möchte auf den Websites der eigenen Restaurants gezeigt werden.", + "adminPublishOff": "Hat nicht darum gebeten, öffentlich gezeigt zu werden." + }, "baseDomain": { "title": "Ihre eigenen Domains", "intro": "Bringen Sie die Restaurants, die Sie verkaufen, unter eine eigene Domain — obresse.ihrefirma.com statt obresse.sofrapiwas.com. Domain hier hinzufügen und nachweisen, dass sie Ihnen gehört; danach wird sie bei jedem neuen Kunden angeboten.", diff --git a/messages/en.json b/messages/en.json index 4800792..0755dd0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -482,7 +482,8 @@ "invoices": "Invoices", "icp": "ICP", "billingDetails": "Billing details", - "domains": "Domains" + "domains": "Domains", + "brand": "Brand" }, "groups": { "pipeline": "Pipeline", @@ -1233,6 +1234,35 @@ "sending": "Sending…", "sent": "Sent — we'll come back to you." }, + "brand": { + "title": "Your public details", + "intro": "The name and contact details a restaurant's guests could be shown as the company behind their website. You type them here, so only what you choose is ever public.", + "privacyNote": "These are kept separate from your billing details on purpose. Your invoice address and registration number stay private and are never shown to anyone but us.", + "prefillNote": "We have filled in your trading name to start you off. Nothing else is copied from your billing details — please type what you are happy for guests to see.", + "noTradeNameNote": "Your billing details give a legal name but no trading name. We cannot publish a personal name on a restaurant's website, so please enter the brand your clients' guests should see.", + "legalNameNotPublished": "This is the same as your legal name, so it will not be shown on any restaurant's site. Your details are still saved — enter a brand name to be credited.", + "fields": { + "displayName": "Display name", + "tagline": "Tagline (optional)", + "websiteUrl": "Website (https://…)", + "email": "Contact email", + "phone": "Phone", + "addressLine1": "Address", + "postalCode": "Postcode", + "city": "City", + "countryCode": "Country (2 letters)" + }, + "publishLabel": "Show these details in the footer of every restaurant site you resell.", + "publishMeaning": "Your customers' guests will see them.", + "publishUnavailable": "Not available yet — publishing stays off until the platform owner enables it. Your details are saved either way.", + "save": "Save", + "saving": "Saving…", + "saved": "Saved.", + "adminTitle": "Their public brand", + "adminEmpty": "No public details entered.", + "adminPublishAsked": "Asked to be shown on their restaurants' sites.", + "adminPublishOff": "Has not asked to be shown publicly." + }, "baseDomain": { "title": "Your own domains", "intro": "Put the restaurants you sell under a domain of your own — obresse.yourcompany.com instead of obresse.sofrapiwas.com. Add the domain here and prove it is yours; from then on it is offered every time you set up a new client.", diff --git a/messages/fr.json b/messages/fr.json index 201d2e5..9cec320 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -482,7 +482,8 @@ "invoices": "Factures", "icp": "ICP", "billingDetails": "Infos de facturation", - "domains": "Domaines" + "domains": "Domaines", + "brand": "Marque" }, "groups": { "pipeline": "Pipeline", @@ -1233,6 +1234,35 @@ "sending": "Envoi…", "sent": "Envoyé — nous revenons vers vous." }, + "brand": { + "title": "Vos coordonnées publiques", + "intro": "Le nom et les coordonnées que les clients d'un restaurant pourraient voir comme l'entreprise derrière leur site. Vous les saisissez ici, afin que seul ce que vous choisissez soit public.", + "privacyNote": "Elles sont volontairement distinctes de vos données de facturation. Votre adresse de facturation et votre numéro d'enregistrement restent privés et ne sont montrés à personne d'autre que nous.", + "prefillNote": "Nous avons prérempli votre nom commercial pour commencer. Rien d'autre n'est copié depuis vos données de facturation — saisissez ce que vous acceptez de montrer aux clients.", + "noTradeNameNote": "Vos données de facturation indiquent une raison sociale mais aucun nom commercial. Nous ne pouvons pas publier un nom de personne sur le site d'un restaurant : saisissez la marque que les clients de vos établissements doivent voir.", + "legalNameNotPublished": "C'est votre raison sociale : elle ne sera affichée sur le site d'aucun restaurant. Vos informations sont tout de même enregistrées — saisissez un nom de marque pour être crédité.", + "fields": { + "displayName": "Nom affiché", + "tagline": "Slogan (facultatif)", + "websiteUrl": "Site web (https://…)", + "email": "E-mail de contact", + "phone": "Téléphone", + "addressLine1": "Adresse", + "postalCode": "Code postal", + "city": "Ville", + "countryCode": "Pays (2 lettres)" + }, + "publishLabel": "Afficher ces coordonnées dans le pied de page de chaque site de restaurant que vous revendez.", + "publishMeaning": "Les clients de vos restaurants les verront.", + "publishUnavailable": "Pas encore disponible — la publication reste désactivée jusqu'à ce que le propriétaire de la plateforme l'active. Vos coordonnées sont enregistrées dans tous les cas.", + "save": "Enregistrer", + "saving": "Enregistrement…", + "saved": "Enregistré.", + "adminTitle": "Sa marque publique", + "adminEmpty": "Aucune coordonnée publique saisie.", + "adminPublishAsked": "A demandé à être affiché sur les sites de ses restaurants.", + "adminPublishOff": "N'a pas demandé à être affiché publiquement." + }, "baseDomain": { "title": "Vos propres domaines", "intro": "Placez les restaurants que vous vendez sous un domaine qui vous appartient — obresse.votresociete.com au lieu de obresse.sofrapiwas.com. Ajoutez le domaine ici et prouvez qu'il est à vous ; il vous sera ensuite proposé à chaque nouveau client.", diff --git a/messages/nl.json b/messages/nl.json index 9978180..8bf2a36 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -482,7 +482,8 @@ "invoices": "Facturen", "icp": "ICP", "billingDetails": "Factuurgegevens", - "domains": "Domeinen" + "domains": "Domeinen", + "brand": "Merk" }, "groups": { "pipeline": "Pijplijn", @@ -1233,6 +1234,35 @@ "sending": "Versturen…", "sent": "Verstuurd — we komen bij je terug." }, + "brand": { + "title": "Uw publieke gegevens", + "intro": "De naam en contactgegevens die gasten van een restaurant kunnen zien als het bedrijf achter hun website. U typt ze hier, zodat alleen wat u kiest openbaar wordt.", + "privacyNote": "Deze staan bewust los van uw factuurgegevens. Uw factuuradres en registratienummer blijven privé en worden aan niemand anders dan ons getoond.", + "prefillNote": "We hebben uw handelsnaam alvast ingevuld. Verder wordt er niets uit uw factuurgegevens overgenomen — typ wat u gasten wilt laten zien.", + "noTradeNameNote": "Uw factuurgegevens bevatten wel een juridische naam maar geen handelsnaam. Een persoonsnaam mogen we niet op de website van een restaurant publiceren — vul het merk in dat de gasten van uw klanten moeten zien.", + "legalNameNotPublished": "Dit is uw juridische naam; die wordt op geen enkele restaurantsite getoond. Uw gegevens worden wel bewaard — vul een merknaam in om vermeld te worden.", + "fields": { + "displayName": "Weergavenaam", + "tagline": "Slogan (optioneel)", + "websiteUrl": "Website (https://…)", + "email": "Contact-e-mail", + "phone": "Telefoon", + "addressLine1": "Adres", + "postalCode": "Postcode", + "city": "Plaats", + "countryCode": "Land (2 letters)" + }, + "publishLabel": "Toon deze gegevens in de voettekst van elke restaurantsite die u doorverkoopt.", + "publishMeaning": "De gasten van uw klanten zien ze.", + "publishUnavailable": "Nog niet beschikbaar — publiceren blijft uit tot de eigenaar van het platform het inschakelt. Uw gegevens worden hoe dan ook bewaard.", + "save": "Opslaan", + "saving": "Opslaan…", + "saved": "Opgeslagen.", + "adminTitle": "Hun publieke merk", + "adminEmpty": "Geen publieke gegevens ingevuld.", + "adminPublishAsked": "Wil getoond worden op de sites van hun restaurants.", + "adminPublishOff": "Heeft niet gevraagd om publiek getoond te worden." + }, "baseDomain": { "title": "Je eigen domeinen", "intro": "Zet de restaurants die je verkoopt onder een eigen domein — obresse.jouwbedrijf.com in plaats van obresse.sofrapiwas.com. Voeg het domein hier toe en bewijs dat het van jou is; daarna wordt het aangeboden bij elke nieuwe klant.", diff --git a/messages/tr.json b/messages/tr.json index e5ce338..863ca3b 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -482,7 +482,8 @@ "invoices": "Faturalar", "icp": "ICP", "billingDetails": "Fatura bilgileri", - "domains": "Alan adları" + "domains": "Alan adları", + "brand": "Marka" }, "groups": { "pipeline": "Süreç", @@ -1233,6 +1234,35 @@ "sending": "Gönderiliyor…", "sent": "Gönderildi — size döneceğiz." }, + "brand": { + "title": "Herkese açık bilgileriniz", + "intro": "Bir restoranın misafirlerinin, sitelerinin arkasındaki şirket olarak görebileceği ad ve iletişim bilgileri. Yalnızca seçtikleriniz herkese açık olsun diye bunları burada siz yazarsınız.", + "privacyNote": "Bunlar fatura bilgilerinizden bilerek ayrı tutulur. Fatura adresiniz ve sicil numaranız gizli kalır ve bizden başka kimseye gösterilmez.", + "prefillNote": "Başlangıç olarak ticari adınızı doldurduk. Fatura bilgilerinizden başka hiçbir şey kopyalanmaz — misafirlerin görmesini istediğiniz bilgileri yazın.", + "noTradeNameNote": "Fatura bilgilerinizde yasal ad var ama ticari ad yok. Bir kişinin adını restoran web sitesinde yayımlayamayız; lütfen müşterilerinizin misafirlerinin göreceği marka adını girin.", + "legalNameNotPublished": "Bu, yasal adınızla aynı; hiçbir restoran sitesinde gösterilmeyecek. Bilgileriniz yine de kaydedilir — adınızın anılması için bir marka adı girin.", + "fields": { + "displayName": "Görünen ad", + "tagline": "Slogan (isteğe bağlı)", + "websiteUrl": "Web sitesi (https://…)", + "email": "İletişim e-postası", + "phone": "Telefon", + "addressLine1": "Adres", + "postalCode": "Posta kodu", + "city": "Şehir", + "countryCode": "Ülke (2 harf)" + }, + "publishLabel": "Bu bilgileri, sattığınız her restoran sitesinin alt bilgisinde gösterin.", + "publishMeaning": "Müşterilerinizin misafirleri bunları görecek.", + "publishUnavailable": "Henüz kullanılamıyor — platform sahibi etkinleştirene kadar yayınlama kapalı kalır. Bilgileriniz yine de kaydedilir.", + "save": "Kaydet", + "saving": "Kaydediliyor…", + "saved": "Kaydedildi.", + "adminTitle": "Herkese açık markası", + "adminEmpty": "Herkese açık bilgi girilmemiş.", + "adminPublishAsked": "Restoranlarının sitelerinde gösterilmek istiyor.", + "adminPublishOff": "Herkese açık gösterilmeyi istemedi." + }, "baseDomain": { "title": "Kendi alan adlarınız", "intro": "Sattığınız restoranları kendi alan adınızın altına alın — obresse.sofrapiwas.com yerine obresse.sirketiniz.com. Alan adını buraya ekleyin ve size ait olduğunu kanıtlayın; sonrasında her yeni müşteride seçenek olarak sunulur.", diff --git a/package-lock.json b/package-lock.json index 3cadab9..7c808d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5487,9 +5487,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.42", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -5540,9 +5540,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "funding": [ { "type": "opencollective", @@ -5559,11 +5559,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -5669,9 +5669,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -5975,16 +5975,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -6054,9 +6044,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.387", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", - "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -6887,9 +6877,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -8789,24 +8779,25 @@ "license": "MIT" }, "node_modules/mysql2": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", - "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.3.tgz", + "integrity": "sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==", "devOptional": true, "license": "MIT", "dependencies": { - "aws-ssl-profiles": "^1.1.1", - "denque": "^2.1.0", + "aws-ssl-profiles": "^1.1.2", "generate-function": "^2.3.1", - "iconv-lite": "^0.7.0", - "long": "^5.2.1", - "lru.min": "^1.0.0", - "named-placeholders": "^1.1.3", - "seq-queue": "^0.0.5", - "sqlstring": "^2.3.2" + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" }, "engines": { "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" } }, "node_modules/named-placeholders": { @@ -9102,9 +9093,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "license": "MIT", "engines": { "node": ">=18" @@ -10215,12 +10206,6 @@ "node": ">=10" } }, - "node_modules/seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "devOptional": true - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10477,14 +10462,20 @@ "node": ">= 10.x" } }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", "devOptional": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, "node_modules/stable-hash": { @@ -11039,9 +11030,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "funding": [ { "type": "opencollective", diff --git a/package.json b/package.json index 94bf1a6..277780c 100644 --- a/package.json +++ b/package.json @@ -52,10 +52,12 @@ "deepmerge-ts": "^8.0.1", "postcss": "^8.5.23", "@hono/node-server": "^2.0.5", - "fast-uri": "^3.1.4", + "fast-uri": "^3.1.6", "sharp": "^0.35.0", "brace-expansion": "^5.0.8", "@auth/core": "^0.41.3", - "valibot": "^1.4.2" + "valibot": "^1.4.2", + "mysql2": "^3.22.0", + "browserslist": "^4.28.7" } } diff --git a/prisma/migrations/20260830120000_partner_brand/migration.sql b/prisma/migrations/20260830120000_partner_brand/migration.sql new file mode 100644 index 0000000..21aa7d2 --- /dev/null +++ b/prisma/migrations/20260830120000_partner_brand/migration.sql @@ -0,0 +1,52 @@ +-- The public face of a partner (workspace docs/plans/SOFRA-PARTNER-PLAN.md §11). +-- +-- A partner asked to appear in the footer of the restaurant sites they resell. +-- The control plane already held company data about them — but ONLY as +-- `BillingIdentity`, which is the private legal and tax record: the registered +-- name, the invoice address, a registration number, a VAT number. For a sole +-- trader (the shape of the first reseller) that name is a NATURAL PERSON and +-- that address is where they live. Reusing it would have published someone's +-- home address on a public website in order to save them typing. +-- +-- So this is a SECOND, PUBLIC record, entered by hand, never joined to the +-- first. Only what is typed here can ever be shown. The two tables stay apart so +-- that publishing a brand cannot publish an invoice address. +-- +-- `publishToTenants` DEFAULT FALSE, and nothing reads it yet: the publishing +-- half is gated on an owner decision about what a diner may be told about a +-- third party. The column records the partner's intent rather than leaving it to +-- be inferred later, and the app renders the switch disabled until there is +-- something to switch on. +-- +-- ADDITIVE AND SAFE ON A DB WITH LIVE ROWS: one new table, no column added to, +-- renamed on, or dropped from any existing one, no backfill, no data rewrite. +-- Nothing reads it until the surfaces in this PR do, so applying it before +-- rolling the app is a no-op for every running query plan. +-- +-- PRIMARY KEY is `partnerId` itself, not a surrogate id: a partner has exactly +-- one public brand, and the identity of the row IS the partner. That is also what +-- makes the write an `upsert` on a key the server takes from the session, so no +-- request can name the row it edits. + +CREATE TABLE "PartnerBrand" ( + "partnerId" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "tagline" TEXT, + "websiteUrl" TEXT, + "email" TEXT, + "phone" TEXT, + "addressLine1" TEXT, + "postalCode" TEXT, + "city" TEXT, + "countryCode" TEXT, + "publishToTenants" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PartnerBrand_pkey" PRIMARY KEY ("partnerId") +); + +-- CASCADE: a brand is meaningless without the partner it belongs to, and a +-- departed partner's public details must not outlive their account. +ALTER TABLE "PartnerBrand" ADD CONSTRAINT "PartnerBrand_partnerId_fkey" + FOREIGN KEY ("partnerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a5abf3b..cbfbe04 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -90,6 +90,9 @@ model User { billingsPaid TenantBilling[] @relation("BillingPayer") // Base domains a PARTNER has claimed for their own clients (D1). baseDomains PartnerDomain[] + // The PUBLIC brand a PARTNER may show to their clients' guests (§11). Kept + // apart from `billingIdentity` below on purpose — see PartnerBrand. + brand PartnerBrand? // The legal entity this user is invoiced as (B1). One per user: a party has // one set of registration details at a time. billingIdentity BillingIdentity? @@ -346,6 +349,67 @@ model PartnerDomain { @@index([partnerId]) } +// The PUBLIC face of a partner — what a diner may be shown +// (workspace docs/plans/SOFRA-PARTNER-PLAN.md §11, slice S1). +// +// This is a DIFFERENT THING from `BillingIdentity`, which sits a hundred lines +// above it, and the separation is the entire point of the model rather than a +// tidiness preference. BillingIdentity is the PRIVATE legal and tax record: the +// name a member state registered, the address an invoice is posted to, a +// registration number, a VAT number. For a sole trader — the shape of the first +// reseller — `legalName` is a NATURAL PERSON and `addressLine1` is where that +// person lives. Publishing it because it happened to be the only company data we +// held would put someone's home address in the footer of a restaurant website. +// +// So a partner types their public details a SECOND time, deliberately, and the +// two records are never joined. A field can only ever become public by being +// entered here. `prefillFromBillingIdentity` (lib/partner-brand.ts) carries +// across exactly ONE field, the trade name, and documents why it carries nothing +// else. +// +// Its own model rather than columns on PartnerProfile, for the reason +// PartnerDomain states below: PartnerProfile is a founder-facing CRM row that +// carries `notes`, which are never rendered to the partner — a founder-facing +// row is the wrong home for a field whose audience is the public. +// +// `publishToTenants` defaults to FALSE and **NOTHING CONSUMES IT YET.** No tenant +// site reads this table; the publishing half is gated on an owner decision about +// what a restaurant's guests may be shown about a third party. The column exists +// so the partner's intent is recorded rather than inferred later, and the single +// reader that will ever exist is `renderableBrand()`. +model PartnerBrand { + partnerId String @id + partner User @relation(fields: [partnerId], references: [id], onDelete: Cascade) + + /// The brand as a DINER reads it ("Solution Eva"). Not BillingIdentity.legalName, + /// which for a sole trader is a person's own name. + displayName String + /// One line under the name. Optional; most partners will not want one. + tagline String? + /// `https://` only — see lib/partner-brand.ts. A public link that a tenant page + /// would render, so the scheme is validated rather than assumed. + websiteUrl String? + + /// Contact a guest may be given. A WORK address by intent: the partner typed it + /// here knowing it is public, which is precisely what the billing record's + /// equivalents were never told. + email String? + phone String? + addressLine1 String? + postalCode String? + city String? + /// ISO 3166-1 alpha-2, UPPERCASE when present. Same membership check as the + /// billing record (lib/country-code.ts), but it decides nothing about tax here — + /// it is only ever displayed. + countryCode String? + + /// Opt-in, off by default, and READ BY NOTHING TODAY. See the note above. + publishToTenants Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Client { id String @id @default(cuid()) partnerId String diff --git a/tests/e2e/helpers/db.ts b/tests/e2e/helpers/db.ts index ecd13df..cbe87f2 100644 --- a/tests/e2e/helpers/db.ts +++ b/tests/e2e/helpers/db.ts @@ -706,3 +706,45 @@ export async function findBackupJobs(tenantSlug: string): Promise { + const rows = await query<{ displayName: string; city: string | null; publish: boolean }>( + `SELECT "displayName", city, "publishToTenants" AS publish + FROM "PartnerBrand" WHERE "partnerId" = $1`, + [partnerId], + ); + return rows[0] ?? null; +} + +/** An existing brand, so the edit path is exercised rather than the create path. */ +export async function arrangePartnerBrand( + partnerId: string, + displayName: string, +): Promise { + await query( + `INSERT INTO "PartnerBrand" ("partnerId", "displayName", "updatedAt") + VALUES ($1, $2, now()) + ON CONFLICT ("partnerId") DO UPDATE SET "displayName" = EXCLUDED."displayName"`, + [partnerId, displayName], + ); +} + +/** The legal record, so the prefill can be shown to carry the TRADE name and + * nothing else — the address below is what must never appear on the brand page. */ +export async function arrangeBillingIdentityFor( + userId: string, + opts: { legalName: string; tradeName?: string | null; addressLine1: string }, +): Promise { + await query( + `INSERT INTO "BillingIdentity" + (id, "userId", "legalName", "tradeName", "addressLine1", "postalCode", city, + "countryCode", "billingEmail", "updatedAt") + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '1204', 'Genève', 'CH', + 'billing@example.com', now())`, + [userId, opts.legalName, opts.tradeName ?? null, opts.addressLine1], + ); +} diff --git a/tests/e2e/partner-brand.spec.ts b/tests/e2e/partner-brand.spec.ts new file mode 100644 index 0000000..8627cd1 --- /dev/null +++ b/tests/e2e/partner-brand.spec.ts @@ -0,0 +1,152 @@ +import { expect, test } from "./helpers/fixtures"; +import { RUN_ID, login, uniq } from "./helpers/flows"; +import { + arrangeBillingIdentityFor, + arrangePartnerBrand, + arrangePartnerUser, + findPartnerBrand, +} from "./helpers/db"; + +// A partner recording the PUBLIC details they may one day be shown by +// (SOFRA-PARTNER-PLAN §11). +// +// What this suite proves: the details are stored, the prefill carries the trade +// name and NOT the billing address, the publish switch is present but inert, and +// one partner's save cannot reach another partner's row even when the payload +// says it should. +// +// What it deliberately does NOT try to prove: that anything is published. Nothing +// consumes `publishToTenants` yet — that is §11e, an open owner decision — so +// there is no rendered footer to assert against, and a test that pretended +// otherwise would be asserting a feature that does not exist. + +// Derived from the run id rather than written as a literal, for the reason +// partner-trial.spec.ts documents: a `const PASSWORD = "…"` line is a gitleaks +// `generic-api-key` hit (entropy, not meaning), and a scan that cries wolf on a +// test fixture is a scan people learn to skip past. +const PASSWORD = `e2e-brand-${RUN_ID}`; + +test("a partner saves their public details, and the publish switch is inert", async ({ page }) => { + const email = uniq.email("brandsave"); + const partnerId = await arrangePartnerUser(email, PASSWORD); + + await login(page, { email, password: PASSWORD }); + await page.waitForURL("**/dashboard"); + await page.getByRole("link", { name: /brand/i }).first().click(); + await page.waitForURL("**/dashboard/brand"); + + await page.getByLabel(/display name/i).fill("Solution Eva"); + await page.getByLabel(/^city$/i).fill("Genève"); + await page.getByLabel(/website/i).fill("https://solutioneva.com"); + + // Present, so the partner can see what is being decided about them — and + // disabled, because nothing consumes it. An enabled switch that changed nothing + // would be a lie told to the one person entitled to decide this. + const publish = page.getByRole("checkbox"); + await expect(publish).toBeDisabled(); + await expect(page.getByText(/not available yet/i)).toBeVisible(); + + await page.getByRole("button", { name: /^save$/i }).click(); + await expect(page.getByText(/^saved\.$/i)).toBeVisible(); + + expect(await findPartnerBrand(partnerId)).toMatchObject({ + displayName: "Solution Eva", + city: "Genève", + // A disabled checkbox is not submitted, and the action reads an absent one as + // false. Nothing a partner can do on this page turns publishing on today. + publish: false, + }); +}); + +test("the prefill carries the trade name and NOT the billing address", async ({ page }) => { + const email = uniq.email("brandprefill"); + const partnerId = await arrangePartnerUser(email, PASSWORD); + await arrangeBillingIdentityFor(partnerId, { + legalName: "Eva Obresse", + tradeName: "Solution Eva", + // A home address, which is what a sole trader's billing record actually is. + // It must not appear on a page whose whole subject is what may become public. + addressLine1: "Chemin Privé 7", + }); + + await login(page, { email, password: PASSWORD }); + await page.waitForURL("**/dashboard"); + await page.goto("/dashboard/brand"); + + await expect(page.getByLabel(/display name/i)).toHaveValue("Solution Eva"); + await expect(page.getByLabel(/^address$/i)).toHaveValue(""); + await expect(page.getByLabel(/^postcode$/i)).toHaveValue(""); + await expect(page.getByLabel(/^city$/i)).toHaveValue(""); + // Not merely absent from the fields — absent from the page. + await expect(page.getByText("Chemin Privé 7")).toHaveCount(0); + // And nothing was stored by the mere act of looking: a prefill is a default in + // an editable box, not a write. + expect(await findPartnerBrand(partnerId)).toBeNull(); +}); + +test("a partner cannot write another partner's brand, even by naming them", async ({ page }) => { + const mine = uniq.email("brandmine"); + const theirs = uniq.email("brandtheirs"); + const mineId = await arrangePartnerUser(mine, PASSWORD); + const theirsId = await arrangePartnerUser(theirs, PASSWORD); + await arrangePartnerBrand(theirsId, "Their Brand"); + + await login(page, { email: mine, password: PASSWORD }); + await page.waitForURL("**/dashboard"); + await page.goto("/dashboard/brand"); + + // Their id is INJECTED into the payload — the shape of the attack the action is + // written against. The key comes from the session, so the field is ignored; if + // it were ever read, this is the request that would take over another company's + // public identity. + await page.evaluate((victimId) => { + const form = document.querySelector("form"); + const field = document.createElement("input"); + field.type = "hidden"; + field.name = "partnerId"; + field.value = victimId; + form?.appendChild(field); + }, theirsId); + + await page.getByLabel(/display name/i).fill("My Brand"); + await page.getByRole("button", { name: /^save$/i }).click(); + await expect(page.getByText(/^saved\.$/i)).toBeVisible(); + + expect(await findPartnerBrand(mineId)).toMatchObject({ displayName: "My Brand" }); + expect(await findPartnerBrand(theirsId)).toMatchObject({ displayName: "Their Brand" }); +}); + +test("a sole trader is told why their own name cannot be published (D-B1a)", async ({ page }) => { + const email = uniq.email("brandsole"); + const partnerId = await arrangePartnerUser(email, PASSWORD); + // The case §11b is about: a legal record naming a PERSON, with no trade name. The + // prefill therefore offers that person's own name — correct for a field they are + // about to read and edit, and catastrophic if it were quietly published. + await arrangeBillingIdentityFor(partnerId, { + legalName: "Mustafa Vural", + tradeName: null, + addressLine1: "Chemin Privé 7", + }); + + await login(page, { email, password: PASSWORD }); + await page.waitForURL("**/dashboard"); + await page.goto("/dashboard/brand"); + + // Said before they type, not after they save. + await expect(page.getByText(/no trading name/i)).toBeVisible(); + await expect(page.getByText(/will not be shown on any restaurant/i)).toBeVisible(); + + // The save still SUCCEEDS — it is their record, and refusing the write would leave + // them unable to record anything at all. What is refused is publishing it, and the + // page says so rather than accepting the value and dropping it out of sight. + await page.getByRole("button", { name: /^save$/i }).click(); + await expect(page.getByText(/^saved\.$/i)).toBeVisible(); + expect(await findPartnerBrand(partnerId)).toMatchObject({ displayName: "Mustafa Vural" }); + + // Type a real brand and the warning goes; type the legal name back, in another + // case and with stray spaces, and it returns — the comparison is normalised. + await page.getByLabel(/display name/i).fill("Solution Eva"); + await expect(page.getByText(/will not be shown on any restaurant/i)).toHaveCount(0); + await page.getByLabel(/display name/i).fill(" mustafa VURAL "); + await expect(page.getByText(/will not be shown on any restaurant/i)).toBeVisible(); +}); diff --git a/tests/unit/partner-brand.test.ts b/tests/unit/partner-brand.test.ts new file mode 100644 index 0000000..bd0337f --- /dev/null +++ b/tests/unit/partner-brand.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "vitest"; +import { + checkboxOn, + isLegalNameEcho, + partnerBrandSchema, + prefillFromBillingIdentity, + renderableBrand, + type StoredBrand, +} from "@/lib/partner-brand"; + +// The pure half of a partner's PUBLIC brand (SOFRA-PARTNER-PLAN §11). +// +// Two things are being defended here and they are different. The schema decides +// what a partner is ALLOWED TO STORE — the interesting cases are the refusals, +// because an accepted `javascript:` URL becomes an `href` on a stranger's +// restaurant page. `renderableBrand` decides what may be SHOWN, and its whole +// value is a negative: a complete, well-filled record that was never opted in +// must come back as `null`. + +const parse = (over: Record = {}) => + partnerBrandSchema.safeParse({ displayName: "Solution Eva", publishToTenants: false, ...over }); + +const full: StoredBrand = { + displayName: "Solution Eva", + tagline: "Restaurant software, set up for you", + websiteUrl: "https://solutioneva.com", + email: "hello@solutioneva.com", + phone: "+41 22 000 00 00", + addressLine1: "Rue du Rhône 1", + postalCode: "1204", + city: "Genève", + countryCode: "CH", + publishToTenants: true, +}; + +describe("partnerBrandSchema — displayName", () => { + it("accepts a plain brand name", () => { + const r = parse(); + expect(r.success && r.data.displayName).toBe("Solution Eva"); + }); + it("trims", () => { + const r = parse({ displayName: " Solution Eva " }); + expect(r.success && r.data.displayName).toBe("Solution Eva"); + }); + it("refuses an empty name — the record means nothing without one", () => { + expect(parse({ displayName: "" }).success).toBe(false); + }); + it("refuses whitespace posing as a name", () => { + expect(parse({ displayName: " " }).success).toBe(false); + }); + it("refuses over 80 characters", () => { + expect(parse({ displayName: "a".repeat(81) }).success).toBe(false); + }); +}); + +describe("partnerBrandSchema — websiteUrl is https or nothing", () => { + it("accepts an https address", () => { + const r = parse({ websiteUrl: "https://solutioneva.com" }); + expect(r.success && r.data.websiteUrl).toBe("https://solutioneva.com"); + }); + it("accepts one with a path", () => { + expect(parse({ websiteUrl: "https://solutioneva.com/fr/contact" }).success).toBe(true); + }); + // The three refusals that matter, and why each is not a style preference: + it("refuses http — we would be advertising a downgrade on someone else's site", () => { + expect(parse({ websiteUrl: "http://solutioneva.com" }).success).toBe(false); + }); + it("refuses javascript: — that is script execution in a reader's page", () => { + expect(parse({ websiteUrl: "javascript:alert(1)" }).success).toBe(false); + }); + it("refuses a bare host rather than guessing a scheme for it", () => { + expect(parse({ websiteUrl: "solutioneva.com" }).success).toBe(false); + }); + it("refuses data: too", () => { + expect(parse({ websiteUrl: "data:text/html," }).success).toBe(false); + }); +}); + +describe("partnerBrandSchema — countryCode is a COUNTRY, not two letters", () => { + it("accepts CH", () => { + const r = parse({ countryCode: "CH" }); + expect(r.success && r.data.countryCode).toBe("CH"); + }); + it("uppercases and trims", () => { + const r = parse({ countryCode: " ch " }); + expect(r.success && r.data.countryCode).toBe("CH"); + }); + // The same negative control `lib/country-code.ts` was written for: `SW` is + // assigned to nothing, and it sat on a live record for nine days looking fine. + it("refuses SW, which is not assigned to anything", () => { + expect(parse({ countryCode: "SW" }).success).toBe(false); + }); + it("refuses UK, the common mistake for GB", () => { + expect(parse({ countryCode: "UK" }).success).toBe(false); + }); + it("refuses a three-letter code", () => { + expect(parse({ countryCode: "CHE" }).success).toBe(false); + }); +}); + +describe("partnerBrandSchema — an untouched field is ABSENT, not empty", () => { + it("turns every blank optional into undefined", () => { + const r = parse({ + tagline: "", + websiteUrl: " ", + email: "", + phone: "", + addressLine1: "", + postalCode: "", + city: "", + countryCode: " ", + }); + expect(r.success).toBe(true); + if (!r.success) return; + for (const key of [ + "tagline", + "websiteUrl", + "email", + "phone", + "addressLine1", + "postalCode", + "city", + "countryCode", + ] as const) { + expect(r.data[key], `${key} should be undefined, not ""`).toBeUndefined(); + } + }); + it("still refuses a malformed email when one IS given", () => { + expect(parse({ email: "not-an-address" }).success).toBe(false); + }); + it("caps an over-long optional field", () => { + expect(parse({ tagline: "a".repeat(121) }).success).toBe(false); + }); + it("requires publishToTenants to be a boolean, never a form string", () => { + expect(parse({ publishToTenants: "on" }).success).toBe(false); + }); +}); + +describe("checkboxOn", () => { + it("reads a ticked box", () => expect(checkboxOn("on")).toBe(true)); + it("reads the explicit string", () => expect(checkboxOn("true")).toBe(true)); + it("an absent box is off", () => expect(checkboxOn(null)).toBe(false)); + it("anything else is off — the safe direction for a publish flag", () => { + expect(checkboxOn("yes")).toBe(false); + expect(checkboxOn("1")).toBe(false); + expect(checkboxOn(true)).toBe(false); + }); +}); + +describe("prefillFromBillingIdentity — one field crosses, and only one", () => { + it("takes the trade name", () => { + expect(prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: "Solution Eva" })).toEqual( + { displayName: "Solution Eva" }, + ); + }); + it("falls back to the legal name when there is no trade name", () => { + expect(prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: null })).toEqual({ + displayName: "Eva Obresse", + }); + }); + it("ignores a blank trade name rather than prefilling an empty box", () => { + expect(prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: " " })).toEqual({ + displayName: "Eva Obresse", + }); + }); + it("returns null when there is no identity at all", () => { + expect(prefillFromBillingIdentity(null)).toBeNull(); + expect(prefillFromBillingIdentity(undefined)).toBeNull(); + expect(prefillFromBillingIdentity({ legalName: " ", tradeName: null })).toBeNull(); + }); + // The load-bearing assertion of the whole two-model split: a prefill carries a + // NAME and cannot carry an address, because there is nowhere in its result for + // one to go. + it("carries exactly one key, so no address can ride along", () => { + const out = prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: "Solution Eva" }); + expect(Object.keys(out ?? {})).toEqual(["displayName"]); + }); +}); + +describe("renderableBrand — the single door", () => { + // S3a TIGHTENED this projection to D-B1: `displayName` + `websiteUrl` and nothing + // else. The assertions that named tagline/email/phone/address/postcode/city/country + // were deliberately rewritten, which is the point of the direction — a footer that + // stops carrying a partner's postal address announces itself in red here, whereas + // adding those fields back would be silent and needs its own controls (D-B4). + it("publishes an ATTRIBUTION: a name and a link, and nothing else", () => { + expect(renderableBrand(full)).toEqual({ + displayName: "Solution Eva", + websiteUrl: "https://solutioneva.com", + }); + }); + it("carries no contact details at all, however full the record", () => { + const out = renderableBrand(full) as Record; + for (const key of ["tagline", "email", "phone", "addressLine1", "postalCode", "city", "countryCode"]) { + expect(out, `${key} must not be published`).not.toHaveProperty(key); + } + expect(Object.keys(out).sort()).toEqual(["displayName", "websiteUrl"]); + }); + it("never leaks the flag itself into the projection", () => { + expect(renderableBrand(full)).not.toHaveProperty("publishToTenants"); + }); + it("omits websiteUrl entirely when there is none — absent, never null", () => { + const out = renderableBrand({ ...full, websiteUrl: null }); + expect(out).toEqual({ displayName: "Solution Eva" }); + expect(out).not.toHaveProperty("websiteUrl"); + }); + // Defence in depth: the write schema refuses these, so a row carrying one came from + // somewhere else (an older row, a future admin tool, a hand-edited database). The + // NAME still publishes — the partner asked for that — only the href is dropped. + it("drops a non-https link rather than the whole credit", () => { + for (const bad of ["javascript:alert(1)", "http://solutioneva.com", "solutioneva.com"]) { + expect(renderableBrand({ ...full, websiteUrl: bad })).toEqual({ displayName: "Solution Eva" }); + } + }); + + // THE negative control. A record with every field filled in — the one a naive + // caller would happily render — comes back as null purely because nobody opted + // in. If this ever returns an object, a partner who never consented is in a + // stranger's footer and nothing else in the system goes red. + it("returns null for an UNPUBLISHED brand carrying full data", () => { + expect(renderableBrand({ ...full, publishToTenants: false })).toBeNull(); + }); + it("returns null when there is no brand", () => { + expect(renderableBrand(null)).toBeNull(); + expect(renderableBrand(undefined)).toBeNull(); + }); + it("returns null for a blank display name, whatever else is set", () => { + expect(renderableBrand({ ...full, displayName: " " })).toBeNull(); + }); +}); + +describe("renderableBrand — D-B1a: it refuses the legal name", () => { + // The catastrophic path this exists to close: `prefillFromBillingIdentity` offers + // `tradeName ?? legalName`, so a sole trader with no trade name is offered their own + // name, saves the form untouched, and would otherwise be published on a restaurant's + // page by a convenience nobody decided on. + const sole: StoredBrand = { ...full, displayName: "Mustafa Vural", websiteUrl: null }; + + it("refuses an exact match", () => { + expect(renderableBrand(sole, { legalName: "Mustafa Vural" })).toBeNull(); + }); + // The normalisation cases, and they are the realistic ones: a prefilled value that + // was retyped, pasted with a trailing space, or shouted. + it("refuses a match that differs only by whitespace and case", () => { + expect( + renderableBrand({ ...sole, displayName: " mustafa VURAL " }, { legalName: "Mustafa Vural" }), + ).toBeNull(); + }); + it("refuses when the LEGAL name is the untidy one", () => { + expect(renderableBrand(sole, { legalName: " MUSTAFA vural " })).toBeNull(); + }); + it("refuses the registry's own spelling of that partner", () => { + // SOFRA-BILLING-IDENTITY-PLAN §6 records the one live reseller as `VURAL Mustafa` + // trading as `SOLUTIONEVA` — the order is not the same, so this one must PASS. + expect(renderableBrand(sole, { legalName: "VURAL Mustafa" })).toEqual({ + displayName: "Mustafa Vural", + }); + }); + it("publishes a real brand belonging to the same person", () => { + expect(renderableBrand(full, { legalName: "Mustafa Vural" })).toEqual({ + displayName: "Solution Eva", + websiteUrl: "https://solutioneva.com", + }); + }); + it("does not fire when no legal name is known — the rule needs both records", () => { + expect(renderableBrand(sole)).toEqual({ displayName: "Mustafa Vural" }); + expect(renderableBrand(sole, { legalName: null })).toEqual({ displayName: "Mustafa Vural" }); + expect(renderableBrand(sole, { legalName: " " })).toEqual({ displayName: "Mustafa Vural" }); + }); +}); + +describe("isLegalNameEcho — the predicate the FORM shares with the choke point", () => { + it("is true for the same name written differently", () => { + expect(isLegalNameEcho(" mustafa VURAL ", "Mustafa Vural")).toBe(true); + }); + it("is false for a different name", () => { + expect(isLegalNameEcho("Solution Eva", "Mustafa Vural")).toBe(false); + }); + // Both halves blank would compare equal as "" — and would then hide a brand for a + // reason that has nothing to do with the legal record. + it("is false when either side is empty, rather than trivially true", () => { + expect(isLegalNameEcho("", "")).toBe(false); + expect(isLegalNameEcho(" ", null)).toBe(false); + expect(isLegalNameEcho(null, "Mustafa Vural")).toBe(false); + expect(isLegalNameEcho("Mustafa Vural", undefined)).toBe(false); + }); +}); diff --git a/tests/unit/provisioning-registry.test.ts b/tests/unit/provisioning-registry.test.ts index 292233c..bd1fe2a 100644 --- a/tests/unit/provisioning-registry.test.ts +++ b/tests/unit/provisioning-registry.test.ts @@ -480,3 +480,94 @@ describe("the PR body describes the entry it ships with", () => { expect(body).not.toContain("dig +short"); }); }); + +describe("a partner credit in the generated entry (§11e, S3a)", () => { + // Same regression proof as `base_domain` above, and it is the important half of this + // block: every test in this file that predates `partnerBrand` still passes UNCHANGED. + // Absence is the contract — an entry with no credit is byte-identical to one built + // before the field existed, which is what every entry in the live registry is. + const withBrand = (partnerBrand?: { displayName: string; websiteUrl?: string }) => + asTenant( + buildTenantRegistryEntry({ + slug: "obresse", + name: "O'Bresse", + adminEmail: "chef@obresse.example", + template: "craft", + currency: "CHF", + languages: ["fr"], + modules: ["core"], + partnerBrand, + }), + "obresse", + ) as Record; + + it("emits partner_name and partner_url as flat keys", () => { + // Flat, not a nested `partner:` map: `provision-tenant.sh` reads a fixed key list + // and flattens each value with `str(v)`, so a map would arrive in the shell as a + // stringified Python dict (plan §11d2). + const t = withBrand({ displayName: "Solution Eva", websiteUrl: "https://solutioneva.com" }); + expect(t.partner_name).toBe("Solution Eva"); + expect(t.partner_url).toBe("https://solutioneva.com"); + }); + + it("emits the name alone when the partner recorded no website", () => { + const t = withBrand({ displayName: "Solution Eva" }); + expect(t.partner_name).toBe("Solution Eva"); + expect(t).not.toHaveProperty("partner_url"); + }); + + // THE absence case. Not "partner_name is empty" — the key must not be there at all, + // because an empty value is a thing someone set and an absent key is the default the + // deploy script already implements. + it("emits NEITHER key when there is no publishable brand", () => { + const t = withBrand(); + expect(t).not.toHaveProperty("partner_name"); + expect(t).not.toHaveProperty("partner_url"); + }); + + // D-B2: absent means attribution is ON, so writing the key on every entry would be a + // no-op line on all of them. It is the RESTAURANT's switch, hand-added by the founder + // on their behalf, and `provision-tenant.sh` is where the boolean is resolved. + it("never emits partner_attribution, credited or not", () => { + expect(withBrand({ displayName: "Solution Eva" })).not.toHaveProperty("partner_attribution"); + expect(withBrand()).not.toHaveProperty("partner_attribution"); + }); + + it("escapes a brand name that would otherwise break the YAML", () => { + const t = withBrand({ displayName: "Eva: #1 \"partner\"\nname: pwned" }); + expect(t.partner_name).toBe("Eva: #1 \"partner\"\nname: pwned"); + expect(t.name).toBe("O'Bresse"); + }); + + it("says so in the PR body — the founder's review checkpoint (ADR-012)", () => { + const input = { + slug: "obresse", + name: "O'Bresse", + adminEmail: "chef@obresse.example", + template: "craft" as const, + currency: "CHF", + languages: ["fr"], + modules: ["core"], + partnerBrand: { displayName: "Solution Eva", websiteUrl: "https://solutioneva.com" }, + }; + const body = buildProvisioningPrBody(input); + expect(body).toContain("Solution Eva"); + expect(body).toContain("https://solutioneva.com"); + // The line that makes the section actionable: the founder is the only party who can + // turn it off on the restaurant's behalf, and the key only ever appears to do that. + expect(body).toContain("partner_attribution: false"); + // …and stays silent otherwise, rather than printing an empty section. + const quiet = buildProvisioningPrBody({ ...input, partnerBrand: undefined }); + expect(quiet).not.toContain("partner_attribution"); + expect(quiet).not.toContain("Solution Eva"); + + // A credit with no link says so, rather than describing a link the entry has + // not got: the founder is being asked what the footer will read. + const unlinked = buildProvisioningPrBody({ + ...input, + partnerBrand: { displayName: "Solution Eva" }, + }); + expect(unlinked).toContain("no link"); + expect(unlinked).not.toContain("https://solutioneva.com"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7862516..92728af 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -44,6 +44,10 @@ export default defineConfig({ // have quietly moved already-covered code out of the floor's scope, which reads // as a passing gate rather than as lost coverage. "lib/provisioning-pr-body.ts", + // Its conditional sections, split out for the same limit and listed for the + // same reason. Every branch here is a warning the founder either gets or does + // not get at the one reviewable moment before a tenant is stood up. + "lib/provisioning-pr-blocks.ts", "lib/module-catalog.ts", "lib/tenant-options.ts", "lib/signup-configuration.ts", @@ -109,6 +113,17 @@ export default defineConfig({ // and whether a resolver's answer actually proves control of it. The transport // (`base-domain-dns.ts`) stays out, same split as vies/vies-result. "lib/base-domain.ts", + // §11 — a partner's PUBLIC brand. In scope because its whole job is a + // negative: `renderableBrand` is the single door an unpublished record + // must not get through, and a leak there is silent by construction — it + // looks like a footer with a name in it. The https-only and ISO-country + // refusals are decidable here too, and nowhere cheaper. + "lib/partner-brand.ts", + // The publish half, split out of it for the LOC limit. Listed explicitly for + // the reason provisioning-pr-body.ts is: leaving it off would quietly move + // already-covered code OUT of the floor's scope, which reads as a passing gate + // rather than as lost coverage — and this is the file holding the refusals. + "lib/partner-brand-publish.ts", "lib/base-domain-verification.ts", // D2 — which of the four domain shapes a partner proposed, and what DNS it // needs. Pure by construction: it cannot see whose base domain it was handed or