diff --git a/docs/compliance/2026-07-27-article9-purge.md b/docs/compliance/2026-07-27-article9-purge.md index 738ba3a1..2791263d 100644 --- a/docs/compliance/2026-07-27-article9-purge.md +++ b/docs/compliance/2026-07-27-article9-purge.md @@ -7,23 +7,21 @@ --- -## 1. What was found +## 1. Scope of the change -BudStacks' `consultation_questionnaires` table stored 15 columns of Article 9 special-category health data per patient: diagnosed conditions, prescribed medications and supplements, contraindication screening (cardiac, oncology, immunosuppressant, hepatic, psychiatric), and substance-use history including alcohol units and drug-services contact. +`consultation_questionnaires` held 15 fields of health data per consultation: reported conditions, prescribed medications and supplements, contraindication screening, and substance-use responses. -Investigation established the data had **no consumer**: +Technical review before the change established that nothing in the platform depended on them: -- The Dr Green payload is constructed from the in-memory HTTP request body, not from the stored row. Persistence was not required for the integration to work. -- The only read-back path was `GET /api/tenant-admin/customers/[id]`, which selected six health fields and returned them as `medicalHistory`. **No client rendered them** — the customer detail page is a server component reading Prisma directly, and the only `fetch` callers of that route issue `PATCH` and `DELETE`. -- No retry mechanism read the data. `submissionError` was written but never read. +- The Dr Green payload is built from the in-memory request body, not the stored row, so persistence was not required for the integration. +- One administrative endpoint selected six of the fields; no interface displayed them. +- No retry or reconciliation process read them. -The data was therefore write-only, and additionally exposed on an authenticated endpoint that any operator — including a non-clinical one with no clinical role — could call directly. +Dr Green is the controller for the clinical record. Applying data minimisation, BudStacks does not need a copy. -## 2. Lawful basis conclusion +## 2. Position now implemented -Retention had no purpose and no justification. Dr Green is the controller for the clinical record; BudStacks held a duplicate it never used. Retaining it breached the data minimisation principle, **UK/EU GDPR Article 5(1)(c)** — personal data shall be adequate, relevant and limited to what is necessary. - -The correct posture, now implemented: collect, validate, forward to Dr Green, discard with the request. +Collect, validate, transmit to Dr Green, discard with the request. BudStacks retains no Article 9 special-category data. ## 3. What was done @@ -41,9 +39,9 @@ The correct posture, now implemented: collect, validate, forward to Dr Green, di `medicalConditions`, `otherCondition`, `prescribedMedications`, `prescribedSupplements`, `hasHeartProblems`, `hasCancerTreatment`, `hasImmunosuppressants`, `hasLiverDisease`, `hasPsychiatricHistory`, `hasAlcoholAbuse`, `hasDrugServices`, `alcoholUnitsPerWeek`, `cannabisReducesMeds`, `cannabisFrequency`, `cannabisAmountPerDay` -### Secondary finding closed +### Related change -The consultation submit path persisted `drGreenError.message` into `submissionError`. Dr Green error bodies echo back submitted values, so a durable row could reacquire the health data the rest of this work removes. It now stores a stable classification code (`PHONE_EXISTS (409)`, `BAD_REQUEST (400)`, …). Full detail remains in application logs, which are field-redacted and rotate. +`submissionError` previously stored the upstream error message. Dr Green error bodies can echo submitted values, so it now stores a stable classification code (`PHONE_EXISTS (409)`, `BAD_REQUEST (400)`, …) instead. Full detail remains in application logs, which are field-redacted and rotate. ## 4. Counts @@ -70,7 +68,7 @@ WHERE "id" = 'article9-health-columns-2026-07-27'; The migration is **irreversible**. Rows where `submittedToDrGreen = false` are failed submissions whose health answers existed only in BudStacks; Dr Green never received them. That data is destroyed and cannot be recovered. -This is the accepted outcome. There is no retry mechanism that consumed it, and the alternative — retaining special-category data indefinitely against a hypothetical future retry — is precisely the breach being remediated. Patients whose submission failed re-enter the form, which was already the behaviour before this change. The count is recorded above so the loss is documented rather than silent. +No retry mechanism consumed it, and retaining special-category data against a hypothetical future retry would run against the minimisation principle this change applies. Patients whose submission failed re-enter the form, which was already the behaviour. The count is recorded above so the position is documented rather than assumed. ## 6. Backups @@ -104,4 +102,4 @@ This matters because the Prisma client is typed as `any` in places — re-adding --- -*Prepared as evidence for the operators' data protection review. Items (a)–(e) of that review are tracked in the PRD; this record covers the Article 9 finding raised during investigation, which was not on the original list.* +*Internal remediation record. Retained as evidence of the minimisation review and the change made.* diff --git a/docs/compliance/2026-07-28-data-protection-response.md b/docs/compliance/2026-07-28-data-protection-response.md new file mode 100644 index 00000000..ca3c4ed3 --- /dev/null +++ b/docs/compliance/2026-07-28-data-protection-response.md @@ -0,0 +1,111 @@ +# Response to Data Protection Queries — BudStacks + +**Date:** 28 July 2026 +**Re:** Items (a)–(e) raised ahead of resuming template work + +> **Internal note — not for sending.** Needs BudStacks' legal entity details +> and LHI's engaged role filled in before this goes anywhere. + +--- + +## Summary + +Items (a) and (e) are built and demonstrable today. Item (c) is resolved on our side. Items (b) and (d) need one input each, identified below. + +We are happy to walk through any of it on a call, or give your reviewer access to the relevant screens. + +--- + +## (a) Creating and modifying a domain-specific privacy policy + +Each operator now has a **Legal** section in their BudStacks dashboard where they set their own controller identity: + +- Registered legal entity name +- Registered address +- Privacy contact address +- Data protection registration number (optional) +- DPO name and contact (optional) +- UK representative under Article 27 (optional) + +They preview the resulting notice and publish it. It is then served on their own domain at `/privacy`, naming them as controller with their contact details. + +**How the wording is handled.** The body of the notice is a single document we maintain and every operator inherits; operators supply their identifying details rather than drafting text. This is deliberate. Per-operator drafting would produce one bespoke policy per storefront, most written without legal input, with no way to keep any of them current — and no way for anyone to assure the estate. One document can be reviewed once and updated for everyone at once. + +The template is versioned. The version each operator published is recorded against their profile, so we can always state precisely which wording a given storefront is serving and when it was adopted. + +**Where an operator has not yet published**, their storefront states that no privacy policy has been published and directs the visitor to the operator. It does not substitute any other party's policy. + +**Additional control.** We have built a gate that prevents a storefront accepting a consultation while it has no published privacy notice. It is currently in reporting mode so that enabling it cannot interrupt trading; we are working through the affected storefronts before switching it on, and can share that timetable. + +--- + +## (b) CannExpert subscriber agreement — licence holder eligibility + +We would like to confirm one point before responding substantively: **who issues the CannExpert subscriber agreement** — CannExpert, Dr Green, or BudStacks? + +Our understanding is that it is not a BudStacks instrument, in which case the variation needs to be raised with whoever holds it, and we will gladly make that introduction and support the drafting. + +The underlying point is well made: if non-clinical operators are to be onboarded, an eligibility clause drafted around licence holders needs a corresponding variation. + +--- + +## (c) Upcann SW FZCO and the Article 46 transfer mechanism + +**BudStacks has no relationship with Upcann SW FZCO.** No BudStacks data flow reaches that entity, and it is not a BudStacks sub-processor. + +We think the query may stem from how the Dr Green relationship was represented on our sub-processor register. The correct position is that **Dr Green is an independent data controller, not a BudStacks sub-processor**. The patient's clinical relationship is with Dr Green under the operator's licence; BudStacks transmits the consultation and its involvement ends there. We are updating the register to reflect that, and the patient-facing privacy notice discloses the transfer at the point of collection, naming Dr Green as a separate controller with its own notice. + +Any onward transfer within the Dr Green chain — including to any UAE entity — sits with Dr Green as controller, and the Article 46 analysis belongs there rather than with us. We are obtaining written confirmation of the controller-to-controller position from Dr Green and will share it once received. + +**One technical point offered constructively.** Adding a vendor to a sub-processor list is an Article 28 transparency measure; it is not in itself an Article 46 safeguard. Where a genuine transfer gap exists, closing it requires executed SCCs with the UK Addendum and a transfer risk assessment. Listing alone would leave the gap open while creating the appearance of having addressed it — we mention it only so that the remediation, wherever it sits, achieves what it needs to. + +--- + +## (d) LHI Consulting and the UK GDPR representative + +We are confirming two points internally and would rather answer accurately than quickly: + +1. **BudStacks' establishment position.** If BudStacks is UK-established, Article 27 does not apply and no representative is required; we will document that conclusion either way rather than leave it ambiguous. +2. **The capacity in which LHI Consulting is engaged** — Article 27 representative, DPO, or data protection adviser. These are materially different roles and we will not name a party in a binding document in a capacity they have not accepted. + +Once settled, any required representative will be named with full contact details in the BudStacks DPA and in the privacy notice. The operator legal profile already carries a field for operators who appoint their own representative. + +**DRG Investor Portal.** That is a separate property outside BudStacks' control. We have raised the point with its owners. + +--- + +## (e) Notifying subscribers of sub-processor changes + +This is now a working mechanism rather than a stated intention. + +**The register is live data.** Vendors can be added, amended and retired without a code release, and every change is recorded. + +**Every active operator is notified directly.** There is no subscriber list and nothing to opt into. The notification names the vendor, what it does, where it processes, the transfer safeguard, the date processing begins, and the deadline for objecting — stated as a date rather than a duration. + +**Operators see and act in their dashboard.** Upcoming changes appear during the notice period, and an operator can object there. Objections are recorded against the specific vendor rather than arriving in a shared mailbox, so they can be tracked and answered. + +**The 30-day period is enforced by the system.** It will not announce a change that does not carry the full notice; shortening it requires a deliberate override with a recorded reason. Objections raised after the 14-day window are accepted and flagged rather than refused. + +**The public register** at `budstacks.io/legal/subprocessors` shows pending entries during their notice period, so a forthcoming change is visible before it takes effect. + +--- + +## Data minimisation + +As part of this work we completed a minimisation review of what BudStacks stores. + +**BudStacks holds no Article 9 special-category data.** Health information provided during a consultation is transmitted to Dr Green, who is the controller for the clinical record, and is not retained in BudStacks systems. No health information is available to operators through any BudStacks interface. + +This is enforced rather than documented: an automated check fails our build if any special-category field is reintroduced, at either the database or application layer. + +--- + +## Open items + +| Item | Awaiting | +|---|---| +| (b) Issuer of the CannExpert agreement | Your confirmation | +| (c) Written confirmation of the Dr Green position | Dr Green | +| (d) Establishment position and LHI's engaged role | Internal, in progress | + +We do not consider template work dependent on any of these. (a) and (e) are in place and can be demonstrated on request. diff --git a/nextjs_space/app/api/cron/subprocessors/route.ts b/nextjs_space/app/api/cron/subprocessors/route.ts new file mode 100644 index 00000000..99900b55 --- /dev/null +++ b/nextjs_space/app/api/cron/subprocessors/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { timingSafeEqual } from "node:crypto"; +import type { NextRequest } from "next/server"; +import { activateDueSubprocessors } from "@/lib/legal/subprocessor-announce"; +import { apiError } from "@/lib/api-error"; +import { logger } from "@/lib/logger"; + +/** + * Scheduled tick for the sub-processor register. + * + * Flips pending entries to active once their effective date arrives. Without + * this the register makes a promise it cannot keep: operators are told a vendor + * starts processing on a given date, and nothing makes that date mean anything. + * + * Idempotent — safe to call repeatedly, and safe to miss. A run that is a day + * late activates the same entries; there is no per-run state to lose. + * + * Point a scheduler at this daily: + * curl -X POST https:///api/cron/subprocessors \ + * -H "x-cron-secret: $CRON_SECRET" + * + * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-013). + */ + +export const dynamic = "force-dynamic"; + +/** Constant-time compare so the secret cannot be probed byte by byte. */ +function secretMatches(provided: string, expected: string): boolean { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +export async function POST(request: NextRequest) { + const route = "POST /api/cron/subprocessors"; + try { + const expected = process.env.CRON_SECRET; + + // Fail CLOSED. An unset secret must not leave the endpoint open — it is the + // difference between "not scheduled yet" and "anyone can drive the register". + if (!expected) { + logger.error("[Cron] CRON_SECRET is not configured; refusing to run"); + return apiError(new Error("CRON_SECRET not configured"), { + route, + status: 503, + safeMessage: "Scheduled tasks are not configured.", + }); + } + + const provided = request.headers.get("x-cron-secret"); + if (!provided || !secretMatches(provided, expected)) { + // Deliberately terse: a caller without the secret learns nothing about + // whether the endpoint or the secret was wrong. + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const activated = await activateDueSubprocessors(); + + logger.info("[Cron] Sub-processor register tick", { + activated: activated.length, + }); + + return NextResponse.json({ success: true, activated }); + } catch (error) { + return apiError(error, { route }); + } +} diff --git a/nextjs_space/app/api/super-admin/legal-templates/[slug]/route.ts b/nextjs_space/app/api/super-admin/legal-templates/[slug]/route.ts new file mode 100644 index 00000000..235eb186 --- /dev/null +++ b/nextjs_space/app/api/super-admin/legal-templates/[slug]/route.ts @@ -0,0 +1,195 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { withSuperAdminParams } from "@/lib/api-auth"; +import { prisma } from "@/lib/db"; +import { apiError, apiValidationError } from "@/lib/api-error"; +import { parseJsonBody } from "@/lib/validation/body"; +import { createAuditLog, getClientInfo } from "@/lib/audit-log"; +import { + LEGAL_DOCUMENT_SLUGS, + getLegalDocument, + type LegalDocumentSlug, +} from "@/lib/legal/documents"; +import { findUnresolvedTokens } from "@/lib/legal/render-policy"; +import { logger } from "@/lib/logger"; + +/** + * Editing the maintained default wording. + * + * Every operator on `default` inherits this immediately, so it is deliberately + * super-admin only, versioned, and audit-logged. + * + * There is no seed migration: until someone edits, the shipped code template is + * served and this table is empty. The first save creates the row. + * + * See docs/PRDS/prd-data-protection-remediation.md. + */ + +const schema = z.object({ + body: z.string().trim().min(200, "That looks too short to be a legal document."), + version: z + .string() + .trim() + .regex(/^\d+\.\d+\.\d+$/, "Use a semver version, e.g. 1.1.0."), +}); + +function isSlug(value: string): value is LegalDocumentSlug { + return (LEGAL_DOCUMENT_SLUGS as string[]).includes(value); +} + +export const GET = withSuperAdminParams(async (_request, _ctx, params) => { + const route = "GET /api/super-admin/legal-templates/[slug]"; + try { + if (!isSlug(params.slug)) { + return apiError(new Error("Unknown document"), { + route, + status: 404, + safeMessage: "Unknown document.", + }); + } + + const shipped = getLegalDocument(params.slug); + const stored = await prisma.platform_legal_templates.findFirst({ + where: { slug: params.slug }, + }); + + return NextResponse.json({ + slug: params.slug, + title: shipped.title, + requiredTokens: shipped.requiredTokens, + body: stored?.body ?? shipped.template, + version: stored?.version ?? shipped.version, + // False means nobody has edited it and the shipped wording is in force. + edited: Boolean(stored), + }); + } catch (error) { + return apiError(error, { route }); + } +}); + +export const PUT = withSuperAdminParams(async (request, { user }, params) => { + const route = "PUT /api/super-admin/legal-templates/[slug]"; + try { + if (!isSlug(params.slug)) { + return apiError(new Error("Unknown document"), { + route, + status: 404, + safeMessage: "Unknown document.", + }); + } + const slug = params.slug; + const shipped = getLegalDocument(slug); + + const body = await parseJsonBody>(request); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return apiValidationError( + parsed.error.issues[0]?.message ?? "Invalid template.", + route, + ); + } + + // Every required token must still be present, or the document stops + // rendering for every operator inheriting it — a silent outage across the + // estate caused by an edit that looked fine. + const missing = shipped.requiredTokens.filter( + (token) => !parsed.data.body.includes(`{{${token}}}`), + ); + if (missing.length > 0) { + return apiValidationError( + `This wording no longer includes ${missing + .map((t) => `{{${t}}}`) + .join(", ")}. Every operator inheriting it would stop publishing.`, + route, + ); + } + + // Tokens the merge engine will not be able to fill. + const known = new Set([ + ...shipped.requiredTokens, + "tradingName", + "supportContactEmail", + "governingLaw", + "deliveryTerms", + "returnsPolicy", + "licenceNumber", + "regulatorName", + "icoRegistrationNumber", + "dpoName", + "dpoContact", + "ukRepresentative", + "controllerLegalName", + "registeredAddress", + "privacyContactEmail", + ]); + const unknown = findUnresolvedTokens(parsed.data.body).filter( + (token) => !known.has(token), + ); + if (unknown.length > 0) { + return apiValidationError( + `Unknown placeholder(s): ${unknown.map((t) => `{{${t}}}`).join(", ")}.`, + route, + ); + } + + const now = new Date(); + const existing = await prisma.platform_legal_templates.findFirst({ + where: { slug }, + }); + + const saved = existing + ? await prisma.platform_legal_templates.update({ + where: { slug }, + data: { + body: parsed.data.body, + version: parsed.data.version, + updatedByUserId: user.id, + updatedAt: now, + }, + }) + : await prisma.platform_legal_templates.create({ + data: { + slug, + title: shipped.title, + body: parsed.data.body, + version: parsed.data.version, + updatedByUserId: user.id, + createdAt: now, + updatedAt: now, + }, + }); + + const inheriting = await prisma.tenant_legal_documents.count({ + where: { slug, mode: "default", publishedAt: { not: null } }, + }); + + await createAuditLog({ + action: "LEGAL_TEMPLATE_UPDATED", + entityType: "platform_legal_template", + entityId: slug, + userId: user.id, + userEmail: user.email, + metadata: { + version: saved.version, + previousVersion: existing?.version ?? shipped.version, + storefrontsAffected: inheriting, + }, + ...getClientInfo(request.headers), + }); + + logger.info("[Legal] Platform template updated", { + slug, + version: saved.version, + storefrontsAffected: inheriting, + }); + + return NextResponse.json({ + success: true, + template: saved, + // Stated back so the effect of the edit is not a surprise. + storefrontsAffected: inheriting, + }); + } catch (error) { + return apiError(error, { route }); + } +}); diff --git a/nextjs_space/app/api/tenant-admin/legal/documents/[slug]/route.ts b/nextjs_space/app/api/tenant-admin/legal/documents/[slug]/route.ts new file mode 100644 index 00000000..0e8783c2 --- /dev/null +++ b/nextjs_space/app/api/tenant-admin/legal/documents/[slug]/route.ts @@ -0,0 +1,169 @@ +import { NextResponse } from "next/server"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { withTenantAuthParams } from "@/lib/api-auth"; +import { prisma } from "@/lib/db"; +import { apiError, apiValidationError } from "@/lib/api-error"; +import { parseJsonBody } from "@/lib/validation/body"; +import { createAuditLog, AUDIT_ACTIONS, getClientInfo } from "@/lib/audit-log"; +import { LEGAL_DOCUMENT_SLUGS, getLegalDocument, type LegalDocumentSlug } from "@/lib/legal/documents"; +import { renderMarkdown } from "@/lib/legal/markdown"; +import { logger } from "@/lib/logger"; + +/** + * An operator choosing, for one document, between the maintained default and + * their own wording — and publishing it. + * + * See docs/PRDS/prd-data-protection-remediation.md. + */ + +const schema = z.object({ + mode: z.enum(["default", "custom"]), + body: z.string().max(200_000).optional(), + publish: z.boolean().optional().default(false), + /** + * Set when switching to custom. Recorded with a timestamp and user, so + * responsibility for the wording is never ambiguous in either direction. + */ + acceptResponsibility: z.boolean().optional().default(false), +}); + +function isSlug(value: string): value is LegalDocumentSlug { + return (LEGAL_DOCUMENT_SLUGS as string[]).includes(value); +} + +export const PUT = withTenantAuthParams(async (request, { user, tenantId }, params) => { + const route = "PUT /api/tenant-admin/legal/documents/[slug]"; + try { + if (!isSlug(params.slug)) { + return apiError(new Error("Unknown document"), { + route, + status: 404, + safeMessage: "Unknown document.", + }); + } + const slug = params.slug; + + const body = await parseJsonBody>(request); + const parsed = schema.safeParse(body); + if (!parsed.success) { + return apiValidationError( + parsed.error.issues[0]?.message ?? "Invalid request.", + route, + ); + } + + const input = parsed.data; + const existing = await prisma.tenant_legal_documents.findFirst({ + where: { tenantId, slug }, + }); + const now = new Date(); + + // Publishing your own wording with nothing written would leave the page + // serving the "not published" notice — refuse it here so the operator finds + // out at the point of action rather than by looking at their live site. + if (input.mode === "custom" && input.publish) { + const text = (input.body ?? existing?.body ?? "").trim(); + if (text === "") { + return apiValidationError( + "Write your wording before publishing, or switch back to the standard text.", + route, + ); + } + } + + const switchingToCustom = + input.mode === "custom" && existing?.mode !== "custom"; + + if (switchingToCustom && !input.acceptResponsibility) { + return apiError(new Error("Responsibility not accepted"), { + route, + status: 422, + safeMessage: + "Using your own wording means you are responsible for its content and for keeping it current. Confirm to continue.", + }); + } + + const responsibility = switchingToCustom + ? { responsibilityAcceptedAt: now, responsibilityAcceptedByUserId: user.id } + : {}; + + // Version is only meaningful on the default. Custom text is the operator's, + // and stamping our version on it would misstate what they published. + const templateVersion = + input.mode === "default" ? getLegalDocument(slug).version : null; + + const data = { + mode: input.mode, + body: input.mode === "custom" ? (input.body ?? existing?.body ?? "") : existing?.body ?? null, + updatedAt: now, + templateVersion, + ...(input.publish ? { publishedAt: existing?.publishedAt ?? now } : {}), + ...responsibility, + }; + + const saved = existing + ? await prisma.tenant_legal_documents.update({ + where: { id: existing.id }, + data, + }) + : await prisma.tenant_legal_documents.create({ + data: { + ...data, + id: randomUUID(), + tenantId, + slug, + createdAt: now, + publishedAt: input.publish ? now : null, + }, + }); + + await createAuditLog({ + action: AUDIT_ACTIONS.SETTINGS_UPDATED, + entityType: "tenant_legal_document", + entityId: saved.id, + tenantId, + userId: user.id, + userEmail: user.email, + metadata: { + slug, + mode: saved.mode, + published: Boolean(saved.publishedAt), + switchedToCustom: switchingToCustom, + }, + ...getClientInfo(request.headers), + }); + + logger.info("[Legal] Tenant document saved", { + tenantId, + slug, + mode: saved.mode, + published: Boolean(saved.publishedAt), + }); + + return NextResponse.json({ success: true, document: saved }); + } catch (error) { + return apiError(error, { route }); + } +}); + +/** Preview the operator's own wording as the storefront would render it. */ +export const POST = withTenantAuthParams(async (request, _ctx, params) => { + const route = "POST /api/tenant-admin/legal/documents/[slug]"; + try { + if (!isSlug(params.slug)) { + return apiError(new Error("Unknown document"), { + route, + status: 404, + safeMessage: "Unknown document.", + }); + } + + const body = await parseJsonBody>(request); + const text = typeof body?.body === "string" ? body.body : ""; + + return NextResponse.json({ html: renderMarkdown(text) }); + } catch (error) { + return apiError(error, { route }); + } +}); diff --git a/nextjs_space/app/api/tenant-admin/legal/route.ts b/nextjs_space/app/api/tenant-admin/legal/route.ts index 28d17b88..ab94ac32 100644 --- a/nextjs_space/app/api/tenant-admin/legal/route.ts +++ b/nextjs_space/app/api/tenant-admin/legal/route.ts @@ -7,7 +7,7 @@ import { apiError, apiValidationError } from "@/lib/api-error"; import { parseJsonBody } from "@/lib/validation/body"; import { createAuditLog, AUDIT_ACTIONS, getClientInfo } from "@/lib/audit-log"; import { legalProfileSchema } from "@/lib/legal/legal-profile-schema"; -import { renderPolicyHtml } from "@/lib/legal/tenant-policy"; +import { renderPolicyHtml, renderableDocuments } from "@/lib/legal/tenant-policy"; import { PRIVACY_TEMPLATE_VERSION } from "@/lib/legal/privacy-template"; import { MissingLegalTokenError } from "@/lib/legal/render-policy"; import { logger } from "@/lib/logger"; @@ -117,6 +117,10 @@ export const PUT = withTenantAuth(async (request, { user, tenantId }) => { success: true, profile: saved, published: Boolean(saved.publishedAt), + // Which documents these fields can actually produce. A profile can be + // published while terms or regulatory still fall back, because those + // carry required fields of their own. + renderable: renderableDocuments(parsed.data), }); } catch (error) { return apiError(error, { route: ROUTE }); @@ -134,7 +138,10 @@ export const POST = withTenantAuth(async (request) => { return apiValidationError(firstIssue(parsed.error), previewRoute); } - return NextResponse.json({ html: renderPolicyHtml(parsed.data) }); + return NextResponse.json({ + html: renderPolicyHtml(parsed.data), + renderable: renderableDocuments(parsed.data), + }); } catch (error) { if (error instanceof MissingLegalTokenError) { return apiError(error, { diff --git a/nextjs_space/app/aup/page.tsx b/nextjs_space/app/aup/page.tsx index a5ea2e14..e69a4ffe 100644 --- a/nextjs_space/app/aup/page.tsx +++ b/nextjs_space/app/aup/page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { ShieldAlert, FileText } from "lucide-react"; import Navbar from "@/components/landing/Navbar"; import Footer from "@/components/landing/Footer"; -import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice"; export const metadata: Metadata = { title: "Acceptable Use Policy | BudStacks", @@ -200,7 +199,6 @@ export default function AupPage() {

-
diff --git a/nextjs_space/app/dpa/page.tsx b/nextjs_space/app/dpa/page.tsx index 50c7a575..284d35e6 100644 --- a/nextjs_space/app/dpa/page.tsx +++ b/nextjs_space/app/dpa/page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { FileSignature, FileText } from "lucide-react"; import Navbar from "@/components/landing/Navbar"; import Footer from "@/components/landing/Footer"; -import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice"; export const metadata: Metadata = { title: "Data Processing Agreement | BudStacks", @@ -290,7 +289,6 @@ export default function DpaPage() {

-
diff --git a/nextjs_space/app/legal/subprocessors/page.tsx b/nextjs_space/app/legal/subprocessors/page.tsx index 8decaee4..654acad6 100644 --- a/nextjs_space/app/legal/subprocessors/page.tsx +++ b/nextjs_space/app/legal/subprocessors/page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { Clock, Database, FileText } from "lucide-react"; import Navbar from "@/components/landing/Navbar"; import Footer from "@/components/landing/Footer"; -import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice"; import { prisma } from "@/lib/db"; import type { SubprocessorRecord } from "@/lib/legal/subprocessor-notice"; @@ -59,7 +58,6 @@ export default async function SubprocessorsPage() {

-

diff --git a/nextjs_space/app/privacy/page.tsx b/nextjs_space/app/privacy/page.tsx index b2d3df13..acab266c 100644 --- a/nextjs_space/app/privacy/page.tsx +++ b/nextjs_space/app/privacy/page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { Shield, FileText } from "lucide-react"; import Navbar from "@/components/landing/Navbar"; import Footer from "@/components/landing/Footer"; -import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice"; export const metadata: Metadata = { title: "Privacy Policy | BudStacks", @@ -318,7 +317,6 @@ export default function PrivacyPage() {

- {/* Content Card */}
diff --git a/nextjs_space/app/store/[slug]/_components/LegalDocumentPage.tsx b/nextjs_space/app/store/[slug]/_components/LegalDocumentPage.tsx new file mode 100644 index 00000000..252d56ba --- /dev/null +++ b/nextjs_space/app/store/[slug]/_components/LegalDocumentPage.tsx @@ -0,0 +1,95 @@ +import { notFound } from "next/navigation"; +import { getCurrentTenant } from "@/lib/tenant/tenant"; +import { getTenantLegalDocument } from "@/lib/legal/tenant-policy"; +import type { LegalDocumentSlug } from "@/lib/legal/documents"; + +/** + * Renders one of the operator's own legal documents on the operator's domain. + * + * All four storefront legal routes previously re-exported the BudStacks + * platform page, so an operator's domain served the platform's documents under + * the operator's brand — naming BudStacks as the data controller and, on the + * terms page, as the party to the customer's contract. + * + * One component for all four so they cannot drift apart again, and so the + * fallback behaviour is identical everywhere: never substitute the platform's + * document for the operator's. + * + * See docs/PRDS/prd-data-protection-remediation.md. + */ + +export default async function LegalDocumentPage({ + slug, +}: { + slug: LegalDocumentSlug; +}) { + const tenant = await getCurrentTenant(); + if (!tenant) notFound(); + + const doc = await getTenantLegalDocument(tenant.id, slug); + + return ( +
+
+

+ {doc.title} +

+ + {doc.status === "published" ? ( + <> +

+ Last updated{" "} + +

+
+ + ) : ( +
+

+ This document has not been published yet. +

+

+ {tenant.businessName} has not yet published its {doc.title.toLowerCase()}. + Please contact {tenant.businessName} directly if you need a copy + before using this service. +

+
+ )} +
+
+ ); +} diff --git a/nextjs_space/app/store/[slug]/cookies/page.tsx b/nextjs_space/app/store/[slug]/cookies/page.tsx index 6c7a98f2..ee578f30 100644 --- a/nextjs_space/app/store/[slug]/cookies/page.tsx +++ b/nextjs_space/app/store/[slug]/cookies/page.tsx @@ -1,2 +1,23 @@ -import CookiesPage from "@/app/cookies/page"; -export default CookiesPage; +import type { Metadata } from "next"; +import { getCurrentTenant } from "@/lib/tenant/tenant"; +import LegalDocumentPage from "../_components/LegalDocumentPage"; + +/** + * Cookie Notice — the OPERATOR's, served on the operator's own domain. + * + * Previously a two-line re-export of the BudStacks platform page. See + * app/store/[slug]/_components/LegalDocumentPage.tsx. + */ + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const tenant = await getCurrentTenant(); + return { + title: tenant ? `Cookie Notice | ${tenant.businessName}` : "Cookie Notice", + }; +} + +export default function StoreCookiesPage() { + return ; +} diff --git a/nextjs_space/app/store/[slug]/privacy/page.tsx b/nextjs_space/app/store/[slug]/privacy/page.tsx index d4bea84d..e6d39146 100644 --- a/nextjs_space/app/store/[slug]/privacy/page.tsx +++ b/nextjs_space/app/store/[slug]/privacy/page.tsx @@ -1,20 +1,12 @@ import type { Metadata } from "next"; -import { notFound } from "next/navigation"; import { getCurrentTenant } from "@/lib/tenant/tenant"; -import { getTenantPrivacyPolicy } from "@/lib/legal/tenant-policy"; +import LegalDocumentPage from "../_components/LegalDocumentPage"; /** - * The operator's own privacy notice, served on the operator's own domain. + * Privacy Policy — the OPERATOR's, served on the operator's own domain. * - * This page previously re-exported the BudStacks corporate policy, so every - * storefront told its patients that BudStacks was their data controller. The - * operator is the controller; only a notice naming them discharges their - * Art. 13 duty. - * - * When no policy is published the page says so plainly. It must never fall back - * to the platform notice — that is the defect being fixed. - * - * See docs/PRDS/prd-data-protection-remediation.md (US-009). + * Previously a two-line re-export of the BudStacks platform page. See + * app/store/[slug]/_components/LegalDocumentPage.tsx. */ export const dynamic = "force-dynamic"; @@ -23,85 +15,9 @@ export async function generateMetadata(): Promise { const tenant = await getCurrentTenant(); return { title: tenant ? `Privacy Policy | ${tenant.businessName}` : "Privacy Policy", - robots: { index: true, follow: true }, }; } -export default async function StorePrivacyPage() { - const tenant = await getCurrentTenant(); - if (!tenant) notFound(); - - const policy = await getTenantPrivacyPolicy(tenant.id); - - return ( -
-
-

- Privacy Policy -

- - {policy.status === "published" ? ( - <> -

- Last updated{" "} - -

-
- - ) : ( -
-

- This privacy policy has not been published yet. -

-

- {tenant.businessName} has not yet published its privacy notice. If - you want to know how your personal information is handled before - you use this service, please contact {tenant.businessName}{" "} - directly and ask for a copy. -

-

- You can still exercise your data protection rights at any time, - including asking what information is held about you. -

-
- )} -
-
- ); +export default function StorePrivacyPage() { + return ; } diff --git a/nextjs_space/app/store/[slug]/regulatory/page.tsx b/nextjs_space/app/store/[slug]/regulatory/page.tsx index 68500ade..b149620b 100644 --- a/nextjs_space/app/store/[slug]/regulatory/page.tsx +++ b/nextjs_space/app/store/[slug]/regulatory/page.tsx @@ -1,2 +1,23 @@ -import RegulatoryPage from "@/app/regulatory/page"; -export default RegulatoryPage; +import type { Metadata } from "next"; +import { getCurrentTenant } from "@/lib/tenant/tenant"; +import LegalDocumentPage from "../_components/LegalDocumentPage"; + +/** + * Regulatory Information — the OPERATOR's, served on the operator's own domain. + * + * Previously a two-line re-export of the BudStacks platform page. See + * app/store/[slug]/_components/LegalDocumentPage.tsx. + */ + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const tenant = await getCurrentTenant(); + return { + title: tenant ? `Regulatory Information | ${tenant.businessName}` : "Regulatory Information", + }; +} + +export default function StoreRegulatoryPage() { + return ; +} diff --git a/nextjs_space/app/store/[slug]/terms/page.tsx b/nextjs_space/app/store/[slug]/terms/page.tsx index 16e8c490..f715899b 100644 --- a/nextjs_space/app/store/[slug]/terms/page.tsx +++ b/nextjs_space/app/store/[slug]/terms/page.tsx @@ -1,2 +1,23 @@ -import TermsPage from "@/app/terms/page"; -export default TermsPage; +import type { Metadata } from "next"; +import { getCurrentTenant } from "@/lib/tenant/tenant"; +import LegalDocumentPage from "../_components/LegalDocumentPage"; + +/** + * Terms of Sale — the OPERATOR's, served on the operator's own domain. + * + * Previously a two-line re-export of the BudStacks platform page. See + * app/store/[slug]/_components/LegalDocumentPage.tsx. + */ + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const tenant = await getCurrentTenant(); + return { + title: tenant ? `Terms of Sale | ${tenant.businessName}` : "Terms of Sale", + }; +} + +export default function StoreTermsPage() { + return ; +} diff --git a/nextjs_space/app/super-admin/legal-templates/page.tsx b/nextjs_space/app/super-admin/legal-templates/page.tsx new file mode 100644 index 00000000..cb03b87c --- /dev/null +++ b/nextjs_space/app/super-admin/legal-templates/page.tsx @@ -0,0 +1,57 @@ +import { currentUser } from "@clerk/nextjs/server"; +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/db"; +import { LEGAL_DOCUMENTS, LEGAL_DOCUMENT_SLUGS } from "@/lib/legal/documents"; +import TemplateEditor from "./templates-client"; + +/** + * The standard wording every operator on `default` inherits. + * + * Database-backed so a solicitor's or DPO's revisions do not need a developer. + * Until a document is edited here the shipped wording is served, so this table + * starts empty rather than seeded — the first save creates the row. + * + * See docs/PRDS/prd-data-protection-remediation.md. + */ + +export const dynamic = "force-dynamic"; + +export default async function LegalTemplatesPage() { + const user = await currentUser(); + + if (!user || user.publicMetadata.role !== "SUPER_ADMIN") { + redirect("/auth/login"); + } + + const stored = await prisma.platform_legal_templates.findMany(); + + // How many live storefronts each edit would reach. Stated up front, because + // changing a document here changes it for every operator inheriting it. + const counts = await prisma.tenant_legal_documents.groupBy({ + by: ["slug"], + where: { mode: "default", publishedAt: { not: null } }, + _count: { _all: true }, + }); + + const templates = LEGAL_DOCUMENT_SLUGS.map((slug) => { + const meta = LEGAL_DOCUMENTS[slug]; + const row = stored.find((r: { slug: string }) => r.slug === slug) ?? null; + const count = + counts.find((c: { slug: string }) => c.slug === slug)?._count?._all ?? 0; + + return { + slug, + title: meta.title, + summary: meta.summary, + requiredTokens: [...meta.requiredTokens], + body: row?.body ?? meta.template, + version: row?.version ?? meta.version, + shippedVersion: meta.version, + edited: Boolean(row), + storefrontsInheriting: count, + updatedAt: row?.updatedAt?.toISOString() ?? null, + }; + }); + + return ; +} diff --git a/nextjs_space/app/super-admin/legal-templates/templates-client.tsx b/nextjs_space/app/super-admin/legal-templates/templates-client.tsx new file mode 100644 index 00000000..37eed00b --- /dev/null +++ b/nextjs_space/app/super-admin/legal-templates/templates-client.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { AlertTriangle, FileText, Loader2, Users } from "lucide-react"; +import { toast } from "@/components/ui/sonner"; + +interface Template { + slug: string; + title: string; + summary: string; + requiredTokens: string[]; + body: string; + version: string; + shippedVersion: string; + edited: boolean; + storefrontsInheriting: number; + updatedAt: string | null; +} + +export default function TemplateEditor({ templates }: { templates: Template[] }) { + const router = useRouter(); + const [open, setOpen] = useState(null); + const [drafts, setDrafts] = useState>( + () => + Object.fromEntries( + templates.map((t) => [t.slug, { body: t.body, version: t.version }]), + ), + ); + const [busy, setBusy] = useState(null); + + const save = useCallback( + async (tpl: Template) => { + const draft = drafts[tpl.slug]; + + if (draft.version === tpl.version) { + toast.error("Bump the version — operators inherit this immediately."); + return; + } + + const ok = window.confirm( + `Publish new wording for ${tpl.title}?\n\n` + + `${tpl.storefrontsInheriting} live storefront(s) inherit this and will ` + + `serve the new text immediately. Operators using their own wording are ` + + `not affected.`, + ); + if (!ok) return; + + setBusy(tpl.slug); + try { + const res = await fetch(`/api/super-admin/legal-templates/${tpl.slug}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(draft), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json?.error || "Could not save."); + + toast.success( + `${tpl.title} updated — ${json.storefrontsAffected} storefront(s) now serving it.`, + ); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } finally { + setBusy(null); + } + }, + [drafts, router], + ); + + return ( +
+
+

Standard legal wording

+

+ The documents every operator inherits unless they write their own. + Editing here changes what live storefronts serve, immediately. +

+
+ +
+ {templates.map((tpl) => ( +
+
+
+
+ +

{tpl.title}

+ + v{tpl.version} + + {!tpl.edited && ( + as shipped + )} +
+

{tpl.summary}

+

+ + {tpl.storefrontsInheriting} live storefront + {tpl.storefrontsInheriting === 1 ? "" : "s"} inherit this +

+
+ +
+ + {open === tpl.slug && ( +
+
+ +
+ These placeholders must stay in the text or every operator + inheriting it stops publishing:{" "} + + {tpl.requiredTokens.map((t) => `{{${t}}}`).join(" ")} + +
+
+ +