Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions scripts/Update-Legal.sql
Original file line number Diff line number Diff line change
@@ -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'
-- 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();
70 changes: 45 additions & 25 deletions src/v1/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'>;
Expand Down Expand Up @@ -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<LegalStatus> {
private async getEffectiveLegalDocuments(): Promise<LegalVersionRow[]> {
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<LegalStatus> {
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')
Expand Down Expand Up @@ -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,
};
});

Expand All @@ -425,18 +452,11 @@ export class AuthService {
): Promise<LegalStatus> {
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<LegalDocumentRow, 'kind' | 'current_version'>[] | 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');
}
Expand All @@ -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 },
);
Expand Down
158 changes: 158 additions & 0 deletions supabase/updates/021_scheduled_legal_updates.sql
Original file line number Diff line number Diff line change
@@ -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();
-- =============================================================================
1 change: 1 addition & 0 deletions supabase/updates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading