From d36e5ab2e76b83a091bf6971cd8a1849ccf74b43 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Fri, 31 Jul 2026 00:04:46 +0900 Subject: [PATCH] feat(legal): scheduled multi-document legal updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New legal_document_versions table holds published and scheduled versions per document; the current version is the highest one whose effective_at has passed, so a future-dated insert activates the re-accept gate on its own — several documents at once when they share an effective_at. legal_documents stays as the kind registry. API computes effective versions at read time (no cron); response shape unchanged, so the CropWatch accept-terms page needs no changes. Update-Legal.sql is now the scheduling template (auto version numbering, review/cancel queries). Co-Authored-By: Claude Opus 5 (1M context) --- database.types.ts | 32 ++++ scripts/Update-Legal.sql | 30 +++- src/v1/auth/auth.service.ts | 70 +++++--- .../updates/021_scheduled_legal_updates.sql | 158 ++++++++++++++++++ supabase/updates/README.md | 1 + 5 files changed, 263 insertions(+), 28 deletions(-) create mode 100644 supabase/updates/021_scheduled_legal_updates.sql diff --git a/database.types.ts b/database.types.ts index c38d706..663f176 100644 --- a/database.types.ts +++ b/database.types.ts @@ -2136,6 +2136,38 @@ export type Database = { }, ] } + legal_document_versions: { + Row: { + created_at: string + effective_at: string + kind: string + url: string + version: number + } + Insert: { + created_at?: string + effective_at?: string + kind: string + url: string + version: number + } + Update: { + created_at?: string + effective_at?: string + kind?: string + url?: string + version?: number + } + Relationships: [ + { + foreignKeyName: "legal_document_versions_kind_fkey" + columns: ["kind"] + isOneToOne: false + referencedRelation: "legal_documents" + referencedColumns: ["kind"] + }, + ] + } legal_documents: { Row: { current_version: number diff --git a/scripts/Update-Legal.sql b/scripts/Update-Legal.sql index ee9270c..78e9d63 100644 --- a/scripts/Update-Legal.sql +++ b/scripts/Update-Legal.sql @@ -1,3 +1,27 @@ - UPDATE public.legal_documents - SET current_version = current_version + 1, effective_at = now(), updated_at = now() - WHERE kind = 'eula'; -- or 'terms_of_service' / 'privacy_policy' \ No newline at end of file +-- Schedule (or immediately publish) a legal document update. +-- +-- List every document changing in THIS update — one row per document, all +-- sharing the same effective_at, so users are gated on all of them at once and +-- re-accept them together. Use now() as effective_at to publish immediately; +-- a future timestamp activates the re-accept gate by itself at that moment +-- (no deploy, no cron). Version numbers are assigned automatically. +-- Mechanism: api/supabase/updates/021_scheduled_legal_updates.sql + +INSERT INTO public.legal_document_versions (kind, version, url, effective_at) +SELECT u.kind, + (SELECT COALESCE(MAX(v.version), 0) + 1 + FROM public.legal_document_versions v + WHERE v.kind = u.kind), + u.url, + u.effective_at +FROM (VALUES -- keep only the rows for the documents that changed + ('eula', 'https://www.cropwatch.io/legal/EULA', timestamptz '2026-09-01 00:00:00+09'), + ('terms_of_service', 'https://www.cropwatch.io/legal/terms-of-service', timestamptz '2026-09-01 00:00:00+09'), + ('privacy_policy', 'https://www.cropwatch.io/legal/privacy-policy', timestamptz '2026-09-01 00:00:00+09') +) AS u(kind, url, effective_at); + +-- Review scheduled-but-not-yet-effective updates: +-- SELECT * FROM public.legal_document_versions WHERE effective_at > now() ORDER BY effective_at, kind; + +-- Cancel a scheduled update before it takes effect: +-- DELETE FROM public.legal_document_versions WHERE kind = 'eula' AND effective_at > now(); diff --git a/src/v1/auth/auth.service.ts b/src/v1/auth/auth.service.ts index f5f80ac..33006a2 100644 --- a/src/v1/auth/auth.service.ts +++ b/src/v1/auth/auth.service.ts @@ -21,7 +21,7 @@ const RESTRICTED_EMAIL_CHANGE_DOMAINS = ['@cropwatch.io', '@cropwatch.co.jp']; type ProfileRow = TableRow<'profiles'>; type PreferencesRow = TableRow<'profile_preferences'>; -type LegalDocumentRow = TableRow<'legal_documents'>; +type LegalVersionRow = TableRow<'legal_document_versions'>; type LegalAcceptanceRow = TableRow<'profile_legal_acceptances'>; type WhatsNewRow = TableRow<'whats_new'>; type WhatsNewSeenRow = TableRow<'profile_whats_new_seen'>; @@ -350,25 +350,52 @@ export class AuthService { } /** - * Which legal documents (ToS / EULA / privacy) the caller still has to - * accept: their latest recorded acceptance per document vs. the currently - * published version in legal_documents. + * The currently effective version row per document kind: the highest + * version in legal_document_versions whose effective_at has passed. Rows + * with a future effective_at are scheduled updates — invisible here until + * their moment arrives, at which point the re-accept gate activates on its + * own (several kinds sharing one effective_at gate together). */ - async getLegalStatus(user: AuthenticatedUser): Promise { + private async getEffectiveLegalDocuments(): Promise { const client = this.supabaseService.getClient(); - const userId = user.sub; - const { data: documents, error: documentsError } = (await client - .from('legal_documents') + const { data: versions, error } = (await client + .from('legal_document_versions') .select('*') - .order('kind')) as { - data: LegalDocumentRow[] | null; + .lte('effective_at', new Date().toISOString()) + .order('kind') + .order('version', { ascending: false })) as { + data: LegalVersionRow[] | null; error: PostgrestError | null; }; - if (documentsError || !documents) { + if (error || !versions) { throw new InternalServerErrorException('Failed to read legal documents'); } + // Sorted kind ASC, version DESC — the first row of each kind is current. + const current: LegalVersionRow[] = []; + for (const row of versions) { + if ( + current.length === 0 || + current[current.length - 1].kind !== row.kind + ) { + current.push(row); + } + } + return current; + } + + /** + * Which legal documents (ToS / EULA / privacy) the caller still has to + * accept: their latest recorded acceptance per document vs. the currently + * effective version in legal_document_versions. + */ + async getLegalStatus(user: AuthenticatedUser): Promise { + const client = this.supabaseService.getClient(); + const userId = user.sub; + + const documents = await this.getEffectiveLegalDocuments(); + const { data: acceptances, error: acceptancesError } = (await client .from('profile_legal_acceptances') .select('kind, version, accepted_at') @@ -398,13 +425,13 @@ export class AuthService { } return { kind: doc.kind, - current_version: doc.current_version, + current_version: doc.version, url: doc.url, effective_at: doc.effective_at, accepted_version: acceptedVersion, accepted_at: acceptedAt, needs_acceptance: - acceptedVersion === null || acceptedVersion < doc.current_version, + acceptedVersion === null || acceptedVersion < doc.version, }; }); @@ -425,18 +452,11 @@ export class AuthService { ): Promise { const client = this.supabaseService.getClient(); const userId = user.sub; - const kinds = [...new Set(dto.kinds)]; + const kinds: string[] = [...new Set(dto.kinds)]; - const { data: documents, error: documentsError } = (await client - .from('legal_documents') - .select('kind, current_version') - .in('kind', kinds)) as { - data: Pick[] | null; - error: PostgrestError | null; - }; - if (documentsError || !documents) { - throw new InternalServerErrorException('Failed to read legal documents'); - } + const documents = (await this.getEffectiveLegalDocuments()).filter((doc) => + kinds.includes(doc.kind), + ); if (documents.length !== kinds.length) { throw new BadRequestException('Unknown legal document kind'); } @@ -447,7 +467,7 @@ export class AuthService { documents.map((doc) => ({ user_id: userId, kind: doc.kind, - version: doc.current_version, + version: doc.version, })), { onConflict: 'user_id,kind,version', ignoreDuplicates: true }, ); diff --git a/supabase/updates/021_scheduled_legal_updates.sql b/supabase/updates/021_scheduled_legal_updates.sql new file mode 100644 index 0000000..d9d82c2 --- /dev/null +++ b/supabase/updates/021_scheduled_legal_updates.sql @@ -0,0 +1,158 @@ +-- ============================================================================= +-- 021_scheduled_legal_updates.sql +-- Scheduled (future-dated) legal document updates, covering one or more +-- documents in a single update event. +-- +-- A) legal_document_versions — one row per published OR scheduled version of +-- each document. The CURRENT version of a document is the highest version +-- whose effective_at has passed; scheduling an update is just inserting +-- future-dated rows (see the OPS footer / scripts/Update-Legal.sql). +-- Nothing promotes rows and no cron is involved — the API computes the +-- effective version at read time, so the re-accept gate activates by +-- itself the moment effective_at arrives. Several documents inserted with +-- the same effective_at form one update event: users are gated on all of +-- them at once and re-accept them together on the accept-terms page. +-- +-- legal_documents remains as the document-kind registry (and FK target of +-- profile_legal_acceptances.kind); its current_version / url / +-- effective_at columns are superseded by this table and marked deprecated. +-- They are NOT kept in sync after this script runs. +-- +-- B) Seed — each document's currently published row in legal_documents is +-- copied in as its latest version, so behavior is unchanged at cutover. +-- +-- C) handle_new_user() — full 020 body, with the signup-consent insert now +-- stamping the current EFFECTIVE version from legal_document_versions +-- instead of legal_documents.current_version. +-- Run order: 019, 020, then 021 (this REPLACE includes both). +-- +-- Deploy order: run this script first (the live API keeps reading the +-- untouched legal_documents rows), then deploy the API release that reads +-- legal_document_versions. Only schedule updates after that deploy — the old +-- API never looks at this table, so an earlier insert would just stay silent. +-- +-- RLS is enabled with no anon/authenticated policies, matching the posture of +-- 002_enable_rls_all_public.sql: the API uses the service-role client and +-- enforces authorization in Nest. +-- +-- Idempotent: CREATE ... IF NOT EXISTS / CREATE OR REPLACE / ON CONFLICT. +-- Regenerate database.types.ts (api + CropWatch) after running. +-- ============================================================================= + +BEGIN; + +-- --------------------------------------------------------------------------- +-- A) legal_document_versions — published + scheduled versions per document +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.legal_document_versions ( + kind text NOT NULL REFERENCES public.legal_documents (kind), + version integer NOT NULL CHECK (version >= 1), + url text NOT NULL, + effective_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (kind, version) +); + +ALTER TABLE public.legal_document_versions ENABLE ROW LEVEL SECURITY; + +COMMENT ON TABLE public.legal_document_versions IS + 'Published and scheduled versions of each legal document. The current version of a kind is the highest version with effective_at <= now(); rows with a future effective_at are scheduled updates that activate on their own.'; + +COMMENT ON COLUMN public.legal_documents.current_version IS + 'DEPRECATED (021): superseded by legal_document_versions; not kept in sync. The table itself remains as the kind registry.'; +COMMENT ON COLUMN public.legal_documents.url IS + 'DEPRECATED (021): superseded by legal_document_versions; not kept in sync.'; +COMMENT ON COLUMN public.legal_documents.effective_at IS + 'DEPRECATED (021): superseded by legal_document_versions; not kept in sync.'; + +-- --------------------------------------------------------------------------- +-- B) Seed — copy each document's currently published state as its latest row +-- --------------------------------------------------------------------------- +INSERT INTO public.legal_document_versions (kind, version, url, effective_at) +SELECT kind, current_version, url, effective_at +FROM public.legal_documents +ON CONFLICT (kind, version) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- C) handle_new_user() — 020 body, consent stamped from effective versions +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger + LANGUAGE plpgsql SECURITY DEFINER + SET search_path TO 'public' + AS $$ +BEGIN + INSERT INTO public.profiles ( + id, username, full_name, employer, avatar_url, email, created_at + ) + VALUES ( + NEW.id, + NEW.raw_user_meta_data->>'username', + COALESCE( + NEW.raw_user_meta_data->>'full_name', + NULLIF(TRIM(CONCAT_WS(' ', + NEW.raw_user_meta_data->>'first_name', + NEW.raw_user_meta_data->>'last_name')), '') + ), + COALESCE( + NEW.raw_user_meta_data->>'employer', + NEW.raw_user_meta_data->>'company' + ), + NEW.raw_user_meta_data->>'avatar_url', + COALESCE(NEW.raw_user_meta_data->>'email', NEW.email), + NOW() + ) + ON CONFLICT (id) DO UPDATE + SET email = COALESCE(EXCLUDED.email, public.profiles.email); + + INSERT INTO public.profile_legal_acceptances (user_id, kind, version) + SELECT NEW.id, cv.kind, cv.version + FROM ( + SELECT DISTINCT ON (kind) kind, version + FROM public.legal_document_versions + WHERE effective_at <= now() + ORDER BY kind, version DESC + ) cv + WHERE (cv.kind = 'privacy_policy' AND NEW.raw_user_meta_data->>'agreed_privacy' = 'true') + OR (cv.kind = 'terms_of_service' AND NEW.raw_user_meta_data->>'agreed_terms' = 'true') + OR (cv.kind = 'eula' AND NEW.raw_user_meta_data->>'agreed_eula' = 'true') + ON CONFLICT DO NOTHING; + + INSERT INTO public.profile_whats_new_seen (user_id, release) + SELECT NEW.id, wn.current_release + FROM public.whats_new wn + WHERE wn.key = 'app' + ON CONFLICT DO NOTHING; + + RETURN NEW; +END; +$$; + +COMMIT; + +-- ============================================================================= +-- OPS: scheduling (or immediately publishing) a legal update +-- +-- Maintained copy: scripts/Update-Legal.sql. List every document changing in +-- this update — one row per document, all sharing the same effective_at so +-- users re-accept them all at once. Use now() to publish immediately. Version +-- numbers are assigned automatically. No deploy needed. +-- +-- INSERT INTO public.legal_document_versions (kind, version, url, effective_at) +-- SELECT u.kind, +-- (SELECT COALESCE(MAX(v.version), 0) + 1 +-- FROM public.legal_document_versions v +-- WHERE v.kind = u.kind), +-- u.url, +-- u.effective_at +-- FROM (VALUES -- keep only the rows for the documents that changed +-- ('eula', 'https://www.cropwatch.io/legal/EULA', timestamptz '2026-09-01 00:00:00+09'), +-- ('terms_of_service', 'https://www.cropwatch.io/legal/terms-of-service', timestamptz '2026-09-01 00:00:00+09'), +-- ('privacy_policy', 'https://www.cropwatch.io/legal/privacy-policy', timestamptz '2026-09-01 00:00:00+09') +-- ) AS u(kind, url, effective_at); +-- +-- Review scheduled-but-not-yet-effective updates: +-- SELECT * FROM public.legal_document_versions WHERE effective_at > now() ORDER BY effective_at, kind; +-- +-- Cancel one before it takes effect (harmless while effective_at is future): +-- DELETE FROM public.legal_document_versions WHERE kind = 'eula' AND effective_at > now(); +-- ============================================================================= diff --git a/supabase/updates/README.md b/supabase/updates/README.md index eece631..7c6710f 100644 --- a/supabase/updates/README.md +++ b/supabase/updates/README.md @@ -30,6 +30,7 @@ Full background: [`docs/security-review.md`](../../docs/security-review.md), | `018_line_action_type.sql` | Seeds `cw_rule_action_types` with the LINE action (id 4) — data-driven rules-UI option | **Last** — only after the alert service with `LineAlertActionHandler` is deployed | | `019_legal_documents.sql` | Creates `legal_documents` (versioned ToS/EULA/privacy) + `profile_legal_acceptances` (append-only audit), extends `handle_new_user()` to record signup consent (and fixes the first_name/last_name/company metadata mismatch), backfills existing users at v1 | Before deploying the legal re-acceptance API release; regenerate `database.types.ts` (api + CropWatch) after. Publish an update later via the OPS `UPDATE` in the script footer | | `020_whats_new.sql` | Creates `whats_new` (single-row announcement flag, seeded at release 0) + `profile_whats_new_seen` (per-user dismiss tracking), extends `handle_new_user()` to pre-seed new signups as already-seen | **After 019.** Before deploying the What's New API release; regenerate `database.types.ts` (api + CropWatch) after. Activate an announcement via the OPS `UPDATE` in the script footer, only after the app deploy containing the matching content | +| `021_scheduled_legal_updates.sql` | Creates `legal_document_versions` (published + scheduled versions per document; the current version is the highest one whose `effective_at` has passed, so future-dated inserts activate the re-accept gate on their own, several documents at once when they share an `effective_at`), seeds it from `legal_documents` (which stays as the kind registry, its version columns deprecated), points `handle_new_user()` at it | **After 020.** Run before deploying the scheduled-legal-updates API release; regenerate `database.types.ts` after. Only schedule updates once that release is live — via `scripts/Update-Legal.sql` | ## Deploy/run interleaving (critical)