From 956e0819f4777c13805326421c6aecd7b2ee3e4a Mon Sep 17 00:00:00 2001 From: Doui Date: Sun, 30 Aug 2026 19:27:38 +0530 Subject: [PATCH 1/2] feat(guide-disclaimers): add content-category disclaimer banners to guides - Add disclaimer and guide_disclaimers tables with RLS and seed data - Add disclaimer service (load/replace) for guide bases - Wire disclaimers through guide and revision services and API routes - Add shared Zod schemas for disclaimer enums, requests, and responses - Add DisclaimerBanner component with bold label, icon, and separate messages - Add disclaimer multi-select combobox to guide contribute/edit flow - Render banner on guide and variant reader pages - Add AGENTS.md with monorepo contributor guidance Signed-off-by: Doui --- AGENTS.md | 179 ++++++++++++++++++ api/src/database.types.ts | 90 ++++++++- api/src/routes/guides.ts | 2 + api/src/services/disclaimer.service.ts | 70 +++++++ api/src/services/guide-revision.service.ts | 26 ++- api/src/services/guide.service.ts | 17 +- app/src/components/DisclaimerBanner.tsx | 39 ++++ .../contribute/ContributionFlow.tsx | 6 + .../contribute/steps/GuideDetails.tsx | 33 +++- .../lib/__tests__/contributionStorage.test.ts | 2 + .../routes/guides/$slug/$variantSlug/edit.tsx | 2 + .../guides/$slug/$variantSlug/index.tsx | 6 + .../$variantSlug/revisions.$revisionId.tsx | 1 + app/src/routes/guides/$slug/index.tsx | 5 + app/src/routes/review.$caseId.tsx | 1 + app/src/types/contributions.ts | 9 + packages/schemas/src/guides/enums.ts | 10 + packages/schemas/src/guides/requests.ts | 3 + packages/schemas/src/guides/responses.ts | 4 + .../20260830000000_guide_disclaimers.sql | 49 +++++ supabase/seed.sql | 16 ++ 21 files changed, 557 insertions(+), 13 deletions(-) create mode 100644 AGENTS.md create mode 100644 api/src/services/disclaimer.service.ts create mode 100644 app/src/components/DisclaimerBanner.tsx create mode 100644 supabase/migrations/20260830000000_guide_disclaimers.sql diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..9bb08ef7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,179 @@ +# AGENTS.md + +## Project at a glance + +Open-source, prerequisite-graph education platform. pnpm monorepo with three +workspace packages: + +| Package | Path | What it is | +|---|---|---| +| `app` | `app/` | React 19 · TanStack Start (SSR) · TanStack Router · shadcn/ui · Tailwind 4 · Vite | +| `api` | `api/` | Hono on Cloudflare Workers · OpenAPI auto-generated · Zod validation | +| `@bluelearn/schemas` | `packages/schemas/` | Shared Zod schemas (requests, responses, enums) consumed by both app and api | + +Database: Supabase (Postgres 17, GoTrue Auth, RLS). Search: Typesense (separate service). + +## Commands + +All run from the repo root via pnpm. + +```bash +pnpm install # install all deps +pnpm dev # app (port 3000) + api (port 8787) in parallel +pnpm dev:app # frontend only (Vite) +pnpm dev:api # API only (wrangler dev + tsc --watch) + +pnpm build # build all packages +pnpm typecheck # typecheck all packages +pnpm lint # lint all packages +pnpm format # prettier --write +pnpm format:check # prettier --check (CI uses this) +pnpm test # test all packages (Vitest) +``` + +### Single-package commands + +```bash +pnpm --filter app typecheck +pnpm --filter app lint +pnpm --filter app test +pnpm --filter app build + +pnpm --filter api typecheck +pnpm --filter api lint +pnpm --filter api test +pnpm --filter api exec vitest run # run API tests directly + +pnpm --filter @bluelearn/schemas typecheck +pnpm --filter @bluelearn/schemas lint +``` + +### Database + +```bash +pnpm supabase:start # requires Docker +pnpm supabase:stop +pnpm supabase:reset # drop + recreate + reseed +pnpm supabase:types # regenerate api/src/database.types.ts (local) +pnpm supabase:types:remote # regenerate from hosted Supabase +``` + +### Deployment + +```bash +pnpm api:deploy # wrangler deploy +``` + +### CI order (per package) + +CI runs these jobs in parallel across packages: +- **format**: `pnpm format:check` +- **app**: typecheck → lint → test → build +- **api**: typecheck → lint → dry-deploy (`wrangler deploy --dry-run`) +- **schemas**: typecheck → lint + +## Environment setup + +Two env files, different prefixes: + +1. Copy `api/.dev.vars.example` → `api/.dev.vars` (Supabase URL, keys, Typesense, APP_URL) +2. Copy `app/.env.example` → `app/.env` (VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_API_BASE) +3. Fill values from `pnpm supabase:start` output. + +**Critical**: Frontend env vars use `VITE_` prefix; API vars are plain. They are NOT interchangeable. + +## Architecture + +``` +browser → app (Vite SSR, port 3000) → api (Wrangler, port 8787) → Supabase (Postgres + Auth) +``` + +- **app/** never talks to Supabase directly. All data flows through api/. +- **api/** is stateless; state lives in Postgres. Rate limiter uses a Durable Object (SQLite-backed), falls back to in-memory in dev/test. +- **Type safety**: `api/src/index.ts` exports `AppType`. The frontend imports it via `hc()` for fully typed HTTP calls (request + response + validated body). + +### API middleware stack + +Applied in order: CORS → global rate limit (READ) → `supabaseMiddleware()` → per-route auth (`requireUser`) → per-route rate limits → `validate()` (Zod). + +Routes mounted before `supabaseMiddleware()` (e.g., `/avatar`) are unauthenticated. + +### Key files + +| File | Why it matters | +|---|---| +| `api/src/index.ts` | AppType export, route mounting, cron entry point | +| `api/src/types.ts` | Bindings type (env vars) | +| `api/src/middleware/auth.middleware.ts` | `supabaseMiddleware`, `requireUser` | +| `api/src/middleware/rateLimits.ts` | Rate limit presets (CREATE, READ, SEARCH, etc.) | +| `app/src/lib/api/apiClient.ts` | Hono typed client with auto auth headers | +| `app/src/lib/authContext.tsx` | `useAuth()`, `useRequireRole()` | +| `packages/schemas/src/` | All shared Zod schemas (guides, objectives, subjects, etc.) | +| `api/src/database.types.ts` | Generated Postgres types — regenerate after schema changes | + +### Cron triggers + +- Every 5 min: `assemblePendingPanels`, `sweepExpiredReviewSeats` +- Every 12 hours: `promoteAllCanonicals` + +## Conventions + +### Branching and commits + +- Branch: `/` (e.g., `feat/concept-prefetch`) +- PR title: `(): ` where type is `feat|fix|docs|refactor|chore|test|perf|ci` +- Commits: Conventional Commits style, not enforced. Must include `Signed-off-by:` (`git commit -s`). + +### Code style + +- **Prettier**: double quotes, semicolons, 2-space indent, trailing commas (es5), LF line endings. +- **Path alias**: `@/` maps to `app/src/`. Always use `@/lib/x`, never `../../../lib/x`. +- **No `// @ts-ignore`** without a comment explaining why. +- **No drive-by reformatting** in bug-fix PRs. +- **Tailwind classes**: Use `cn()` from `app/src/lib/utils.ts` for conditional classes. Prettier sorts Tailwind via plugin. + +### Frontend routing + +File-based with TanStack Router in `app/src/routes/`. Layout routes use ``. Routes marked `ssr: false` are client-only (e.g., review). Loaders use `loader: async ({ abortController }) => {...}`. + +### API route pattern + +Each resource is a Hono router in `api/src/routes/`. Pattern: +```typescript +export const fooRouter = new Hono() + .get("/", describeRoute({...}), validate("query", schema), handler) + .post("/", describeRoute({...}), requireUser, rateLimitMiddleware({...}), validate("json", schema), handler); +``` + +Services live in `api/src/services/*.service.ts`, throw `ServiceError` for HTTP failures. + +### Database + +- Migrations: timestamp-prefixed `.sql` files in `supabase/migrations/`. +- Complex operations are PostgreSQL RPCs (defined in migrations, called via Supabase client). +- RLS policies enforce per-user access. API uses per-request Supabase client with the user's JWT. +- After schema changes: `pnpm supabase:types` to regenerate `api/src/database.types.ts`. + +### Tests + +- **App**: Vitest with `--passWithNoTests`. Tests in `app/src/lib/__tests__/`. +- **API**: Vitest in `api/tests/`. Tests create real users via `admin.auth.admin.createUser()` and run against the local Supabase DB. Factory helpers in `api/tests/factories/`. +- **OpenAPI validation**: `api/tests/openapi.ts` validates responses against generated spec. + +## Gotchas + +- Docker must be running for `supabase start`. +- `api/src/database.types.ts` is generated, not hand-written. Regenerate after any migration. +- API CORS only allows `APP_URL` from `.dev.vars`. Wrong value = silent CORS failures. +- The `RATE_LIMITER` Durable Object binding is optional in dev. Tests run without it and use in-memory counters. +- The app's `typecheck` script runs `pnpm --filter api build` first (builds API declarations needed for `AppType` import). This is intentional. +- `app/src/routeTree.gen.ts` is auto-generated by TanStack Router. Never edit it manually. +- `api/src/database.types.ts` and `app/src/routeTree.gen.ts` are both in `.prettierignore`. + +## Instruction sources + +- `CONTRIBUTING.md` — full contributor guide (setup, PR process, review expectations) +- `.github/copilot-instructions.md` — companion file with similar guidance for Copilot +- `docs/architecture.md` — system diagram and boundary rationale +- `docs/monorepo.md` — why one repo instead of three +- `.github/workflows/ci.yml` — CI pipeline definition diff --git a/api/src/database.types.ts b/api/src/database.types.ts index e364c042..cdbd8a14 100644 --- a/api/src/database.types.ts +++ b/api/src/database.types.ts @@ -7,13 +7,54 @@ export type Json = | Json[] export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "14.5" + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } } public: { Tables: { + disclaimers: { + Row: { + description: string | null + id: string + label: string + slug: string + } + Insert: { + description?: string | null + id?: string + label: string + slug: string + } + Update: { + description?: string | null + id?: string + label?: string + slug?: string + } + Relationships: [] + } guide_bases: { Row: { canonical_guide_id: string | null @@ -72,6 +113,43 @@ export type Database = { }, ] } + guide_disclaimers: { + Row: { + disclaimer_id: string + guide_base_id: string + } + Insert: { + disclaimer_id: string + guide_base_id: string + } + Update: { + disclaimer_id?: string + guide_base_id?: string + } + Relationships: [ + { + foreignKeyName: "guide_disclaimers_disclaimer_id_fkey" + columns: ["disclaimer_id"] + isOneToOne: false + referencedRelation: "disclaimers" + referencedColumns: ["id"] + }, + { + foreignKeyName: "guide_disclaimers_guide_base_id_fkey" + columns: ["guide_base_id"] + isOneToOne: false + referencedRelation: "guide_bases" + referencedColumns: ["id"] + }, + { + foreignKeyName: "guide_disclaimers_guide_base_id_fkey" + columns: ["guide_base_id"] + isOneToOne: false + referencedRelation: "published_guides" + referencedColumns: ["id"] + }, + ] + } guide_edges: { Row: { created_at: string @@ -1410,6 +1488,9 @@ export type CompositeTypes< : never export const Constants = { + graphql_public: { + Enums: {}, + }, public: { Enums: { app_role: ["verifier", "moderator", "curator", "admin", "official"], @@ -1451,3 +1532,4 @@ export const Constants = { }, }, } as const + diff --git a/api/src/routes/guides.ts b/api/src/routes/guides.ts index d0d88e48..b388c2c0 100644 --- a/api/src/routes/guides.ts +++ b/api/src/routes/guides.ts @@ -567,6 +567,7 @@ export const guideRevisionsRouter = new Hono() prerequisites, todos, revised_from_case_id, + disclaimers, } = await getRevision(c.get("supabase"), c.req.valid("param").id); return c.json({ revision, @@ -578,6 +579,7 @@ export const guideRevisionsRouter = new Hono() prerequisites, todos, revised_from_case_id, + disclaimers, }); } ) diff --git a/api/src/services/disclaimer.service.ts b/api/src/services/disclaimer.service.ts new file mode 100644 index 00000000..9a9f9116 --- /dev/null +++ b/api/src/services/disclaimer.service.ts @@ -0,0 +1,70 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { DisclaimerSlug } from "@bluelearn/schemas"; +import type { Database } from "../database.types"; +import { ServiceError } from "../lib/service-error"; + +type DB = SupabaseClient; + +// Load disclaimer slugs for a guide base. +export async function loadDisclaimers( + supabase: DB, + baseId: string +): Promise { + const { data, error } = await supabase + .from("guide_disclaimers") + .select("disclaimers!inner(slug)") + .eq("guide_base_id", baseId); + + if (error) { + console.error(error); + throw new ServiceError("Failed to load disclaimers", 500); + } + return (data ?? []) + .map((r) => r.disclaimers?.slug) + .filter((s): s is DisclaimerSlug => s != null); +} + +// Replace a guide base's disclaimer set. +export async function replaceDisclaimers( + supabase: DB, + baseId: string, + slugs: DisclaimerSlug[] +) { + const unique = [...new Set(slugs)]; + + const { error: delError } = await supabase + .from("guide_disclaimers") + .delete() + .eq("guide_base_id", baseId); + if (delError) { + console.error(delError); + throw new ServiceError("Unable to update disclaimers", 400); + } + + if (unique.length === 0) return; + + const { data: disclaimerRows, error: lookupError } = await supabase + .from("disclaimers") + .select("id, slug") + .in("slug", unique); + if (lookupError) { + console.error(lookupError); + throw new ServiceError("Failed to resolve disclaimers", 500); + } + if ((disclaimerRows ?? []).length !== unique.length) { + throw new ServiceError("Unknown disclaimer slug", 400); + } + + const { error: insError } = await supabase + .from("guide_disclaimers") + .insert( + disclaimerRows!.map((d) => ({ + guide_base_id: baseId, + disclaimer_id: d.id, + })) + ); + if (insError) { + console.error(insError); + throw new ServiceError("Unable to update disclaimers", 400); + } +} diff --git a/api/src/services/guide-revision.service.ts b/api/src/services/guide-revision.service.ts index df2d0bf1..99fbd1c5 100644 --- a/api/src/services/guide-revision.service.ts +++ b/api/src/services/guide-revision.service.ts @@ -1,11 +1,13 @@ import type { SupabaseClient } from "@supabase/supabase-js"; import type { UpdateRevisionInput } from "@bluelearn/schemas"; +import type { DisclaimerSlug } from "@bluelearn/schemas"; import type { Database } from "../database.types"; import { ServiceError } from "../lib/service-error"; import { diffField } from "../lib/diff"; import { createSubject } from "./subject.service"; import { createPrerequisite } from "./prerequisite.service"; import { createTodo } from "./todo.service"; +import { loadDisclaimers, replaceDisclaimers } from "./disclaimer.service"; type DB = SupabaseClient; @@ -232,7 +234,7 @@ export async function syncDraftTagsAndEdges( } } -// Gets knowledge type, prerequisites, todos, and whether the guide is a variant. +// Gets knowledge type, prerequisites, todos, disclaimers, and whether the guide is a variant. async function loadDraftContext(supabase: DB, guideId: string) { const empty = { knowledge_type: null, @@ -241,6 +243,7 @@ async function loadDraftContext(supabase: DB, guideId: string) { variant_slug: null, prerequisites: [], todos: [], + disclaimers: [] as DisclaimerSlug[], }; const { data: guide, error: guideError } = await supabase .from("guides") @@ -254,7 +257,7 @@ async function loadDraftContext(supabase: DB, guideId: string) { if (!guide) return empty; const baseId = guide.guide_base_id; - const [baseRes, edgeRes, todoRes] = await Promise.all([ + const [baseRes, edgeRes, todoRes, disclaimers] = await Promise.all([ supabase .from("guide_bases") .select("knowledge_type, slug, canonical_guide_id") @@ -270,6 +273,7 @@ async function loadDraftContext(supabase: DB, guideId: string) { .select("title, summary") .eq("dependent_guide_base_id", baseId) .eq("status", "open"), + loadDisclaimers(supabase, baseId), ]); if (baseRes.error || edgeRes.error || todoRes.error) { @@ -283,8 +287,6 @@ async function loadDraftContext(supabase: DB, guideId: string) { knowledge_type: baseRes.data?.knowledge_type ?? null, is_variant: canonical != null && canonical !== guideId, base_slug: baseRes.data?.slug ?? null, - // Only set once the guide is published, so it tells whether the editor - // route or the contribute flow owns this draft. variant_slug: guide.slug, prerequisites: (edgeRes.data ?? []) .map((e) => e.from?.slug) @@ -293,6 +295,7 @@ async function loadDraftContext(supabase: DB, guideId: string) { title: t.title, summary: t.summary, })), + disclaimers, }; } @@ -344,6 +347,7 @@ export async function getRevision(supabase: DB, id: string) { variant_slug, prerequisites, todos, + disclaimers, } = context; return { revision, @@ -355,6 +359,7 @@ export async function getRevision(supabase: DB, id: string) { prerequisites, todos, revised_from_case_id, + disclaimers, }; } @@ -388,7 +393,7 @@ export async function updateRevision( id: string, input: UpdateRevisionInput ) { - const { tags, prerequisites, newSubjects, todoPrereqs, ...fields } = input; + const { tags, prerequisites, newSubjects, todoPrereqs, disclaimers, ...fields } = input; const patch = { ...fields, @@ -444,6 +449,17 @@ export async function updateRevision( todoPrereqs, }); + if (disclaimers !== undefined) { + const base = await resolveRevisionBase(supabase, id); + if (base.status !== "draft") { + throw new ServiceError( + "Disclaimers can't be changed from a revision once the guide is published", + 422 + ); + } + await replaceDisclaimers(supabase, base.id, disclaimers); + } + const subjects = await loadRevisionTags(supabase, id); return { revision, subjects }; } diff --git a/api/src/services/guide.service.ts b/api/src/services/guide.service.ts index 03c024bf..c06fd8fb 100644 --- a/api/src/services/guide.service.ts +++ b/api/src/services/guide.service.ts @@ -19,6 +19,7 @@ import { import { claimTodos } from "./todo.service"; import { readingMinutes } from "../lib/reading"; import { loadUsernames } from "./identity.service"; +import { loadDisclaimers, replaceDisclaimers } from "./disclaimer.service"; type DB = SupabaseClient; @@ -210,6 +211,7 @@ export async function createGuide( newSubjects, todoPrereqs, todoClaims, + disclaimers, } = input; const { data: revision_id, error } = await supabase.rpc("create_guide", { @@ -231,6 +233,11 @@ export async function createGuide( todoPrereqs, }); + if (disclaimers.length > 0) { + const base = await resolveRevisionBase(supabase, revision_id); + await replaceDisclaimers(supabase, base.id, disclaimers); + } + if (todoClaims.length > 0) { const base = await resolveRevisionBase(supabase, revision_id); await claimTodos(supabase, base.id, todoClaims); @@ -273,6 +280,8 @@ async function loadPrerequisites( .sort((a, b) => a.title.localeCompare(b.title)); } + + export async function getGuideBySlug(supabase: DB, rawSlug: string) { const slug = rawSlug.toLowerCase(); @@ -292,9 +301,10 @@ export async function getGuideBySlug(supabase: DB, rawSlug: string) { const canonical = guide.canonical; const current = canonical?.current ?? null; - const [subjects, prerequisites] = await Promise.all([ + const [subjects, prerequisites, disclaimers] = await Promise.all([ loadCanonicalTags(supabase, current?.id ?? null), loadPrerequisites(supabase, guide.id), + loadDisclaimers(supabase, guide.id), ]); const authorId = canonical?.author_id ?? null; const usernames = await loadUsernames(supabase, [authorId]); @@ -313,6 +323,7 @@ export async function getGuideBySlug(supabase: DB, rawSlug: string) { tags: subjects.map((s) => ({ slug: s.slug, name: s.name })), prerequisites, is_official: guide.is_official, + disclaimers, }; return detail; @@ -534,7 +545,7 @@ export async function getVariantBySlug( throw new ServiceError("Variant not found", 404); } - const [{ data: tally, error: tallyError }, tags, usernames] = + const [{ data: tally, error: tallyError }, tags, usernames, disclaimers] = await Promise.all([ supabase .from("guide_vote_tallies") @@ -543,6 +554,7 @@ export async function getVariantBySlug( .maybeSingle(), loadCanonicalTags(supabase, variant.current?.id ?? null), loadUsernames(supabase, [variant.author_id]), + loadDisclaimers(supabase, variant.guide_base_id), ]); if (tallyError) { @@ -570,6 +582,7 @@ export async function getVariantBySlug( votes: { up: tally?.upvotes ?? 0, down: tally?.downvotes ?? 0 }, is_official: base?.is_official ?? false, knowledge_type: base?.knowledge_type ?? "theoretical", + disclaimers, }, }; } diff --git a/app/src/components/DisclaimerBanner.tsx b/app/src/components/DisclaimerBanner.tsx new file mode 100644 index 00000000..1329dd9f --- /dev/null +++ b/app/src/components/DisclaimerBanner.tsx @@ -0,0 +1,39 @@ +import { AlertTriangle } from "lucide-react"; +import type { DisclaimerSlug } from "@bluelearn/schemas"; + +const DISCLAIMER_TEXTS: Record = { + medical: + "This article is for informational purposes only and does not constitute medical advice. Always consult a qualified healthcare provider for diagnosis and treatment recommendations.", + legal: + "This information is provided for general informational purposes only and does not constitute legal advice. Reading this content does not create an attorney-client relationship.", + financial: + "This material is for informational purposes only and is not investment, financial, or tax advice. Consult a qualified financial advisor before making decisions. Past performance is not indicative of future results.", +}; + +type PropTypes = { + disclaimers: Array; +}; + +export function DisclaimerBanner({ disclaimers }: PropTypes) { + const texts = disclaimers + .map((slug) => DISCLAIMER_TEXTS[slug]) + .filter(Boolean); + + if (texts.length === 0) return null; + + return ( +
+ {texts.map((text, i) => ( +
+ + + Disclaimer: {text} + +
+ ))} +
+ ); +} diff --git a/app/src/components/contribute/ContributionFlow.tsx b/app/src/components/contribute/ContributionFlow.tsx index 4dcbbc7a..0f596e1f 100644 --- a/app/src/components/contribute/ContributionFlow.tsx +++ b/app/src/components/contribute/ContributionFlow.tsx @@ -75,6 +75,7 @@ const createGuideContData = (): GuideContribution => ({ newSubjects: [], prereqs: [], todoPrereqs: [], + disclaimers: [], }); const createVariantContData = (): VariantContribution => ({ @@ -444,6 +445,7 @@ function Inner({ newSubjects: pending, prereqs: data.prerequisites, todoPrereqs: data.todos, + disclaimers: data.disclaimers, }; setGuideContData(gData); setStoredDraft("guide", { @@ -551,6 +553,7 @@ function Inner({ slug, title: titleBySlug.get(slug) ?? slug, })), + disclaimers: guideContData.disclaimers, }; }, [guideContData, subjectOptions, guideOptions, username]); @@ -580,6 +583,7 @@ function Inner({ })), ], prerequisites: [], + disclaimers: [], }; }, [variantContData, subjectOptions, username]); @@ -599,6 +603,7 @@ function Inner({ prerequisites: guideContData.prereqs, newSubjects: unsavedSubjects(guideContData.newSubjects), todoPrereqs: guideContData.todoPrereqs, + disclaimers: guideContData.disclaimers, }); const variantDraftFields = () => ({ @@ -720,6 +725,7 @@ function Inner({ : "theoretical", ...draftFields(), todoClaims: todoIds, + disclaimers: guideContData.disclaimers, }) : addGuideVariant(variantContData.baseGuide, variantDraftFields()) ) diff --git a/app/src/components/contribute/steps/GuideDetails.tsx b/app/src/components/contribute/steps/GuideDetails.tsx index b540cc18..5ca9415c 100644 --- a/app/src/components/contribute/steps/GuideDetails.tsx +++ b/app/src/components/contribute/steps/GuideDetails.tsx @@ -6,7 +6,6 @@ import type { ContributionType, GuideContribution, } from "@/types/contributions"; - import { StepperActionHeader } from "@/components/contribute/StepperActionHeader"; import { Field, @@ -16,11 +15,18 @@ import { FieldLabel, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; - import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Combobox } from "@/components/ui/combobox"; +const DISCLAIMER_OPTIONS = [ + { value: "medical", label: "Medical" }, + { value: "financial", label: "Financial" }, + { value: "legal", label: "Legal" }, + { value: "mature", label: "Mature age-restricted 18+ content" }, + { value: "profanity", label: "Profanity" }, +]; + type SubjectOption = { id: string; name: string }; type GuideOption = { slug: string | null; @@ -347,6 +353,29 @@ export const GuideDetails = ({ )} + +
+ + Disclaimers + + + Select content-category disclaimers that apply to this guide. + +
+ + + setGuideContData((prev) => ({ + ...prev, + disclaimers: disclaimers as GuideContribution["disclaimers"], + })) + } + /> +
+ {showBaseFields && ( <> diff --git a/app/src/lib/__tests__/contributionStorage.test.ts b/app/src/lib/__tests__/contributionStorage.test.ts index 33dd58e9..c434c07a 100644 --- a/app/src/lib/__tests__/contributionStorage.test.ts +++ b/app/src/lib/__tests__/contributionStorage.test.ts @@ -55,6 +55,7 @@ describe("contributionStorage", () => { newSubjects: [], prereqs: [], todoPrereqs: [], + disclaimers: [], }; setStoredDraft("guide", { @@ -132,6 +133,7 @@ describe("contributionStorage", () => { newSubjects: [], prereqs: [], todoPrereqs: [], + disclaimers: [], }; const { rerender, unmount } = renderHook( diff --git a/app/src/routes/guides/$slug/$variantSlug/edit.tsx b/app/src/routes/guides/$slug/$variantSlug/edit.tsx index f8db8758..a0d79c9e 100644 --- a/app/src/routes/guides/$slug/$variantSlug/edit.tsx +++ b/app/src/routes/guides/$slug/$variantSlug/edit.tsx @@ -84,6 +84,7 @@ function RouteComponent() { })), prereqs: snapshot.prerequisites, todoPrereqs: snapshot.todos, + disclaimers: snapshot.disclaimers, })); const [changeSummary, setChangeSummary] = useState( @@ -160,6 +161,7 @@ function RouteComponent() { slug, title: titleBySlug.get(slug) ?? slug, })), + disclaimers: guideContData.disclaimers, }; }, [ guideContData, diff --git a/app/src/routes/guides/$slug/$variantSlug/index.tsx b/app/src/routes/guides/$slug/$variantSlug/index.tsx index b19b14e8..8bf0436f 100644 --- a/app/src/routes/guides/$slug/$variantSlug/index.tsx +++ b/app/src/routes/guides/$slug/$variantSlug/index.tsx @@ -23,6 +23,7 @@ import { getVariantBySlug } from "@/lib/api/variants"; import "katex/dist/katex.min.css"; import { GuideSidebar } from "@/components/sidebar/GuideSidebar"; import { GuideReader } from "@/components/GuideReader"; +import { DisclaimerBanner } from "@/components/DisclaimerBanner"; import { DropdownMenu, DropdownMenuContent, @@ -76,6 +77,7 @@ function RouteComponent() { created_at: current.created_at, tags: variant.tags, prerequisites: [], + disclaimers: variant.disclaimers, }; const guideMenuItems = [ @@ -235,6 +237,10 @@ function RouteComponent() { + {variant.disclaimers.length > 0 && ( + + )} + + {guide.disclaimers.length > 0 && ( + + )} + {/* Header */} ; diff --git a/packages/schemas/src/guides/requests.ts b/packages/schemas/src/guides/requests.ts index 49964fcc..aa7d700e 100644 --- a/packages/schemas/src/guides/requests.ts +++ b/packages/schemas/src/guides/requests.ts @@ -8,6 +8,7 @@ import { } from "./fields"; import { subjectNameSchema, subjectSummarySchema } from "../subjects"; import { + disclaimerSchema, downvoteReasonSchema, knowledgeTypeSchema, voteDirectionSchema, @@ -48,6 +49,7 @@ export const createGuideSchema = z.object({ newSubjects: z.array(newSubjectSchema).default([]), todoPrereqs: z.array(todoPrereqSchema).default([]), todoClaims: z.array(z.uuid()).default([]), + disclaimers: z.array(disclaimerSchema).default([]), }); // A variant starts as a draft like a guide does, so every field here is optional @@ -68,6 +70,7 @@ export const updateRevisionSchema = revisionContentSchema prerequisites: z.array(guideSlugSchema), newSubjects: z.array(newSubjectSchema), todoPrereqs: z.array(todoPrereqSchema), + disclaimers: z.array(disclaimerSchema), }) .partial() .refine((v) => Object.keys(v).length > 0, { diff --git a/packages/schemas/src/guides/responses.ts b/packages/schemas/src/guides/responses.ts index 333fcf31..290ed2d4 100644 --- a/packages/schemas/src/guides/responses.ts +++ b/packages/schemas/src/guides/responses.ts @@ -6,6 +6,7 @@ import { import { contributorSchema } from "../identity/responses"; import { fieldDiffSchema, revisionRefSchema } from "../diff"; import { + disclaimerSchema, downvoteReasonSchema, guideStatusSchema, knowledgeTypeSchema, @@ -27,6 +28,7 @@ export const guideSchema = z.object({ tags: z.array(subjectReferenceSchema), prerequisites: z.array(guideReferenceSchema), is_official: z.boolean(), + disclaimers: z.array(disclaimerSchema), }); export const walkthroughSchema = z.object({ nodes: z.array( @@ -119,6 +121,7 @@ export const variantSchema = z.object({ }) .nullable(), votes: guideVotesSchema, + disclaimers: z.array(disclaimerSchema), }); export const voteSchema = z.object({ @@ -204,6 +207,7 @@ export const guideRevisionDetailResponseSchema = z.strictObject({ prerequisites: z.array(z.string()), todos: z.array(z.object({ title: z.string(), summary: z.string() })), revised_from_case_id: z.uuid().nullable(), + disclaimers: z.array(disclaimerSchema), }); export const guideRevisionUpdateResponseSchema = z.strictObject({ diff --git a/supabase/migrations/20260830000000_guide_disclaimers.sql b/supabase/migrations/20260830000000_guide_disclaimers.sql new file mode 100644 index 00000000..ef690f75 --- /dev/null +++ b/supabase/migrations/20260830000000_guide_disclaimers.sql @@ -0,0 +1,49 @@ +-- Guide disclaimers: content-category warnings (medical, financial, etc.) +-- Disclaimers live on guide_bases, not individual variants/revisions. + +-- Lookup table of available disclaimer types. +create table public.disclaimers ( + id uuid primary key default gen_random_uuid(), + slug text not null unique, + label text not null, + description text +); + +comment on table public.disclaimers is 'Content-category disclaimer types that can be attached to guides.'; + +-- Junction table linking guide bases to their disclaimers. +create table public.guide_disclaimers ( + guide_base_id uuid not null references public.guide_bases(id) on delete cascade, + disclaimer_id uuid not null references public.disclaimers(id) on delete cascade, + primary key (guide_base_id, disclaimer_id) +); + +comment on table public.guide_disclaimers is 'Associates guide bases with content-category disclaimers.'; + +-- Index for looking up disclaimers by guide base. +create index idx_guide_disclaimers_base on public.guide_disclaimers(guide_base_id); + +-- RLS: anyone can read disclaimers and guide_disclaimers; authenticated users can write guide_disclaimers. +alter table public.disclaimers enable row level security; +alter table public.guide_disclaimers enable row level security; + +create policy "Disclaimers are publicly readable" + on public.disclaimers for select + using (true); + +create policy "Guide disclaimers are publicly readable" + on public.guide_disclaimers for select + using (true); + +create policy "Authenticated users can manage guide disclaimers" + on public.guide_disclaimers for all + using (auth.role() = 'authenticated') + with check (auth.role() = 'authenticated'); + +-- Seed the five disclaimer types. +insert into public.disclaimers (slug, label, description) values + ('medical', 'Medical', 'Content discusses medical topics'), + ('financial', 'Financial', 'Content discusses financial topics'), + ('legal', 'Legal', 'Content discusses legal topics'), + ('mature', 'Mature', 'Content is age-restricted 18+'), + ('profanity', 'Profanity', 'Content contains profanity'); diff --git a/supabase/seed.sql b/supabase/seed.sql index 5f5f5841..8fd390f7 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -400,6 +400,22 @@ insert into public.votes (voter_id, guide_id, direction) values ('00000000-0000-4000-8000-000000000012', '30000000-0000-4000-8000-000000000080', 'up') on conflict do nothing; +-- --------------------------------------------------------------------------- +-- Disclaimer associations for seed data. +-- Disclaimers live on guide_bases so they apply to all variants. +-- --------------------------------------------------------------------------- +insert into public.guide_disclaimers (guide_base_id, disclaimer_id) +select '20000000-0000-4000-8000-000000000003', id from public.disclaimers where slug = 'financial' +on conflict do nothing; -- Deploy to Cloudflare Workers: financial + +insert into public.guide_disclaimers (guide_base_id, disclaimer_id) +select '20000000-0000-4000-8000-000000000004', id from public.disclaimers where slug = 'medical' +on conflict do nothing; -- SQL Joins: medical + +insert into public.guide_disclaimers (guide_base_id, disclaimer_id) +select '20000000-0000-4000-8000-000000000005', id from public.disclaimers where slug = 'legal' +on conflict do nothing; -- Debounce a Search Input: legal + -- Publish guides 1-5 and their variants: point each guide at its live revision, -- each base at its canonical guide, and flip both to published. Guide/base 6 stays draft. update public.guides g From 61faa21832af54af9a7cd43ed2e78fe77b0d56d6 Mon Sep 17 00:00:00 2001 From: Doui Date: Sun, 30 Aug 2026 20:31:41 +0530 Subject: [PATCH 2/2] chore: fix prettier formatting Signed-off-by: Doui --- api/src/services/disclaimer.service.ts | 14 ++++++-------- api/src/services/guide-revision.service.ts | 9 ++++++++- api/src/services/guide.service.ts | 2 -- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/api/src/services/disclaimer.service.ts b/api/src/services/disclaimer.service.ts index 9a9f9116..9feda9db 100644 --- a/api/src/services/disclaimer.service.ts +++ b/api/src/services/disclaimer.service.ts @@ -55,14 +55,12 @@ export async function replaceDisclaimers( throw new ServiceError("Unknown disclaimer slug", 400); } - const { error: insError } = await supabase - .from("guide_disclaimers") - .insert( - disclaimerRows!.map((d) => ({ - guide_base_id: baseId, - disclaimer_id: d.id, - })) - ); + const { error: insError } = await supabase.from("guide_disclaimers").insert( + disclaimerRows!.map((d) => ({ + guide_base_id: baseId, + disclaimer_id: d.id, + })) + ); if (insError) { console.error(insError); throw new ServiceError("Unable to update disclaimers", 400); diff --git a/api/src/services/guide-revision.service.ts b/api/src/services/guide-revision.service.ts index 99fbd1c5..3b1d75d0 100644 --- a/api/src/services/guide-revision.service.ts +++ b/api/src/services/guide-revision.service.ts @@ -393,7 +393,14 @@ export async function updateRevision( id: string, input: UpdateRevisionInput ) { - const { tags, prerequisites, newSubjects, todoPrereqs, disclaimers, ...fields } = input; + const { + tags, + prerequisites, + newSubjects, + todoPrereqs, + disclaimers, + ...fields + } = input; const patch = { ...fields, diff --git a/api/src/services/guide.service.ts b/api/src/services/guide.service.ts index c06fd8fb..96e32e6c 100644 --- a/api/src/services/guide.service.ts +++ b/api/src/services/guide.service.ts @@ -280,8 +280,6 @@ async function loadPrerequisites( .sort((a, b) => a.title.localeCompare(b.title)); } - - export async function getGuideBySlug(supabase: DB, rawSlug: string) { const slug = rawSlug.toLowerCase();