diff --git a/database.types.ts b/database.types.ts index 50310e1..69f3a1d 100644 --- a/database.types.ts +++ b/database.types.ts @@ -2136,6 +2136,30 @@ export type Database = { }, ] } + legal_documents: { + Row: { + current_version: number + effective_at: string + kind: string + updated_at: string + url: string + } + Insert: { + current_version?: number + effective_at?: string + kind: string + updated_at?: string + url: string + } + Update: { + current_version?: number + effective_at?: string + kind?: string + updated_at?: string + url?: string + } + Relationships: [] + } locations: { Row: { created_at: string | null @@ -2217,6 +2241,42 @@ export type Database = { }, ] } + profile_legal_acceptances: { + Row: { + accepted_at: string + kind: string + user_id: string + version: number + } + Insert: { + accepted_at?: string + kind: string + user_id: string + version: number + } + Update: { + accepted_at?: string + kind?: string + user_id?: string + version?: number + } + Relationships: [ + { + foreignKeyName: "profile_legal_acceptances_kind_fkey" + columns: ["kind"] + isOneToOne: false + referencedRelation: "legal_documents" + referencedColumns: ["kind"] + }, + { + foreignKeyName: "profile_legal_acceptances_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } profile_preferences: { Row: { area_unit: string | null diff --git a/src/v1/auth/auth.controller.ts b/src/v1/auth/auth.controller.ts index 6366706..2d4227c 100644 --- a/src/v1/auth/auth.controller.ts +++ b/src/v1/auth/auth.controller.ts @@ -29,6 +29,7 @@ import { LoginResponseDto } from './dto/login-response.dto'; import { UpdateUserProfileDto } from './dto/update-user-profile.dto'; import { UpdateEmailDto } from './dto/update-email.dto'; import { UpdatePreferencesDto } from './dto/update-preferences.dto'; +import { AcceptLegalDto } from './dto/accept-legal.dto'; @Controller({ path: 'auth', version: '1' }) @ApiBearerAuth('bearerAuth') @@ -190,6 +191,57 @@ export class AuthController { return this.authService.updatePreferences(body, user); } + @Get('legal-status') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: + 'Which legal documents (ToS/EULA/privacy) the user still has to accept', + }) + @ApiOkResponse({ + description: 'Per-document acceptance status returned successfully.', + schema: { type: 'object', additionalProperties: true }, + }) + @ApiUnauthorizedResponse({ + description: 'Missing or invalid bearer token.', + type: ErrorResponseDto, + }) + @ApiInternalServerErrorResponse({ + description: 'Failed to read legal status.', + type: ErrorResponseDto, + }) + async getLegalStatus(@CurrentUser() user: AuthenticatedUser) { + return this.authService.getLegalStatus(user); + } + + @Post('legal-acceptance') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: + 'Record acceptance of the current version of the given legal documents', + }) + @ApiOkResponse({ + description: 'Acceptance recorded; fresh legal status returned.', + schema: { type: 'object', additionalProperties: true }, + }) + @ApiBadRequestResponse({ + description: 'Invalid or unknown document kind.', + type: ErrorResponseDto, + }) + @ApiUnauthorizedResponse({ + description: 'Missing or invalid bearer token.', + type: ErrorResponseDto, + }) + @ApiInternalServerErrorResponse({ + description: 'Failed to record legal acceptance.', + type: ErrorResponseDto, + }) + async acceptLegal( + @Body() body: AcceptLegalDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.authService.acceptLegal(body, user); + } + @Throttle({ default: { limit: 2, ttl: 60000 } }) @Post('login') @ApiOperation({ summary: 'Login with email and password' }) diff --git a/src/v1/auth/auth.service.ts b/src/v1/auth/auth.service.ts index 8de4210..b77da18 100644 --- a/src/v1/auth/auth.service.ts +++ b/src/v1/auth/auth.service.ts @@ -13,6 +13,7 @@ import type { TableRow } from '../types/supabase'; import type { AuthenticatedUser } from './authenticated-user'; import { UpdateUserProfileDto } from './dto/update-user-profile.dto'; import { UpdatePreferencesDto } from './dto/update-preferences.dto'; +import { AcceptLegalDto } from './dto/accept-legal.dto'; // Accounts on these domains are locked to their corporate identity and may not // change their email address (checked against the caller's current email). @@ -20,6 +21,23 @@ 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 LegalAcceptanceRow = TableRow<'profile_legal_acceptances'>; + +export interface LegalDocumentStatus { + kind: string; + current_version: number; + url: string; + effective_at: string; + accepted_version: number | null; + accepted_at: string | null; + needs_acceptance: boolean; +} + +export interface LegalStatus { + needs_acceptance: boolean; + documents: LegalDocumentStatus[]; +} /** Shape of a PostgREST single/maybeSingle response from the untyped client. */ type QueryResult = { data: T | null; error: PostgrestError | null }; @@ -323,6 +341,117 @@ export class AuthService { return data; } + /** + * 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. + */ + async getLegalStatus(user: AuthenticatedUser): Promise { + const client = this.supabaseService.getClient(); + const userId = user.sub; + + const { data: documents, error: documentsError } = (await client + .from('legal_documents') + .select('*') + .order('kind')) as { + data: LegalDocumentRow[] | null; + error: PostgrestError | null; + }; + if (documentsError || !documents) { + throw new InternalServerErrorException('Failed to read legal documents'); + } + + const { data: acceptances, error: acceptancesError } = (await client + .from('profile_legal_acceptances') + .select('kind, version, accepted_at') + .eq('user_id', userId)) as { + data: + | Pick[] + | null; + error: PostgrestError | null; + }; + if (acceptancesError) { + throw new InternalServerErrorException( + 'Failed to read legal acceptances', + ); + } + + const documentStatuses: LegalDocumentStatus[] = documents.map((doc) => { + let acceptedVersion: number | null = null; + let acceptedAt: string | null = null; + for (const acceptance of acceptances ?? []) { + if ( + acceptance.kind === doc.kind && + (acceptedVersion === null || acceptance.version > acceptedVersion) + ) { + acceptedVersion = acceptance.version; + acceptedAt = acceptance.accepted_at; + } + } + return { + kind: doc.kind, + current_version: doc.current_version, + url: doc.url, + effective_at: doc.effective_at, + accepted_version: acceptedVersion, + accepted_at: acceptedAt, + needs_acceptance: + acceptedVersion === null || acceptedVersion < doc.current_version, + }; + }); + + return { + needs_acceptance: documentStatuses.some((doc) => doc.needs_acceptance), + documents: documentStatuses, + }; + } + + /** + * Record the caller's acceptance of the CURRENT version of each requested + * document. Versions are stamped server-side; re-accepting an already + * accepted version is a no-op that preserves the original accepted_at. + */ + async acceptLegal( + dto: AcceptLegalDto, + user: AuthenticatedUser, + ): Promise { + const client = this.supabaseService.getClient(); + const userId = user.sub; + const kinds = [...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'); + } + if (documents.length !== kinds.length) { + throw new BadRequestException('Unknown legal document kind'); + } + + const { error: upsertError } = await client + .from('profile_legal_acceptances') + .upsert( + documents.map((doc) => ({ + user_id: userId, + kind: doc.kind, + version: doc.current_version, + })), + { onConflict: 'user_id,kind,version', ignoreDuplicates: true }, + ); + if (upsertError) { + throw new InternalServerErrorException( + 'Failed to record legal acceptance', + ); + } + + return this.getLegalStatus(user); + } + private readBearerToken(authHeader: string | undefined): string { const rawHeader = authHeader?.trim() ?? ''; const [scheme, token] = rawHeader.split(' '); diff --git a/src/v1/auth/dto/accept-legal.dto.ts b/src/v1/auth/dto/accept-legal.dto.ts new file mode 100644 index 0000000..a97bb67 --- /dev/null +++ b/src/v1/auth/dto/accept-legal.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsIn } from 'class-validator'; + +export const LEGAL_DOCUMENT_KINDS = [ + 'privacy_policy', + 'terms_of_service', + 'eula', +] as const; + +export type LegalDocumentKind = (typeof LEGAL_DOCUMENT_KINDS)[number]; + +// Deliberately no version field: the server stamps the current version itself, +// so a client can never record acceptance of a version it was not shown. +export class AcceptLegalDto { + @ApiProperty({ isArray: true, enum: LEGAL_DOCUMENT_KINDS }) + @IsArray() + @ArrayNotEmpty() + @IsIn(LEGAL_DOCUMENT_KINDS, { each: true }) + kinds: LegalDocumentKind[]; +} diff --git a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap index 91b8993..f63d4cc 100644 --- a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap +++ b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap @@ -4,6 +4,25 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot { "components": { "schemas": { + "AcceptLegalDto": { + "properties": { + "kinds": { + "items": { + "enum": [ + "privacy_policy", + "terms_of_service", + "eula", + ], + "type": "string", + }, + "type": "array", + }, + }, + "required": [ + "kinds", + ], + "type": "object", + }, "CreateAirAnnotationDto": { "properties": { "created_at": { @@ -689,6 +708,23 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot }, }, }, + "/v1/auth/legal-acceptance": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AcceptLegalDto", + }, + }, + }, + "required": true, + }, + }, + }, + "/v1/auth/legal-status": { + "get": {}, + }, "/v1/auth/login": { "post": { "requestBody": { diff --git a/src/v1/common/v1-route-input-contract.spec.ts b/src/v1/common/v1-route-input-contract.spec.ts index 39b9060..0ccf704 100644 --- a/src/v1/common/v1-route-input-contract.spec.ts +++ b/src/v1/common/v1-route-input-contract.spec.ts @@ -337,7 +337,7 @@ describe('V1 Route Input Contracts', () => { beforeAll(async () => { serviceRegistry = { air: createMockedMethods(['createNote', 'findOne']), - auth: createMockedMethods(['loginWithPassword']), + auth: createMockedMethods(['loginWithPassword', 'acceptLegal']), devices: createMockedMethods([ 'findAll', 'findAllStatus', @@ -467,6 +467,21 @@ describe('V1 Route Input Contracts', () => { rememberMe: true, }, }, + { + auth: true, + expectedCall: { + args: [{ kinds: ['terms_of_service', 'eula'] }, MOCK_USER], + method: 'acceptLegal', + service: 'auth', + }, + expectedStatus: 201, + method: 'post', + name: 'POST /v1/auth/legal-acceptance accepts the current body shape', + url: '/v1/auth/legal-acceptance', + body: { + kinds: ['terms_of_service', 'eula'], + }, + }, { auth: true, expectedCall: { diff --git a/supabase/updates/019_legal_documents.sql b/supabase/updates/019_legal_documents.sql new file mode 100644 index 0000000..48df531 --- /dev/null +++ b/supabase/updates/019_legal_documents.sql @@ -0,0 +1,145 @@ +-- ============================================================================= +-- 019_legal_documents.sql +-- Versioned legal documents + per-user acceptance audit for the "re-accept +-- updated ToS/EULA/Privacy Policy" gate. +-- +-- A) legal_documents — one row per document kind with the currently published +-- version. Publishing an updated document is a manual UPDATE bumping +-- current_version (see the OPS footer); no deploy is needed — the app +-- gates every user whose latest acceptance is below current_version. +-- +-- B) profile_legal_acceptances — append-only audit of which user accepted +-- which version of which document, and when. Kept in its own table (not +-- columns on `profiles`) so it does not widen the `profiles` selects that +-- are joined into device/location reads everywhere, mirroring the +-- rationale in 014_profile_preferences.sql. profiles.accepted_agreements +-- (a bare boolean, never read or written by any code) is superseded and +-- marked deprecated. +-- +-- C) handle_new_user() — extended so the consent the user gives at signup +-- (agreed_privacy / agreed_terms / agreed_eula in raw_user_meta_data, +-- validated server-side by the create-account action) is recorded as +-- acceptance rows at the then-current versions. Also fixes the metadata +-- key mismatch: signup sends first_name/last_name/company, but the old +-- function only read full_name/employer, leaving both NULL on every new +-- profile. The old keys remain first choice so other creation paths are +-- unaffected. +-- +-- D) Backfill — every existing profile is recorded as having accepted v1 of +-- all three documents (dated from the profile's created_at, since signup +-- has always required consent but never recorded a timestamp). This makes +-- the feature launch silent; the first real version bump gates everyone. +-- +-- 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_documents — one row per document kind +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.legal_documents ( + kind text PRIMARY KEY + CHECK (kind IN ('privacy_policy', 'terms_of_service', 'eula')), + current_version integer NOT NULL DEFAULT 1 CHECK (current_version >= 1), + url text NOT NULL, + effective_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.legal_documents ENABLE ROW LEVEL SECURITY; + +INSERT INTO public.legal_documents (kind, current_version, url) VALUES + ('privacy_policy', 1, 'https://www.cropwatch.io/legal/privacy-policy'), + ('terms_of_service', 1, 'https://www.cropwatch.io/legal/terms-of-service'), + ('eula', 1, 'https://www.cropwatch.io/legal/EULA') +ON CONFLICT (kind) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- B) profile_legal_acceptances — append-only audit +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.profile_legal_acceptances ( + user_id uuid NOT NULL REFERENCES public.profiles (id) ON DELETE CASCADE, + kind text NOT NULL REFERENCES public.legal_documents (kind), + version integer NOT NULL, + accepted_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, kind, version) +); + +ALTER TABLE public.profile_legal_acceptances ENABLE ROW LEVEL SECURITY; + +COMMENT ON COLUMN public.profiles.accepted_agreements IS + 'DEPRECATED (019): superseded by profile_legal_acceptances. Never read; do not use.'; + +-- --------------------------------------------------------------------------- +-- C) handle_new_user() — record signup consent + fix metadata key mismatch +-- --------------------------------------------------------------------------- +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, ld.kind, ld.current_version + FROM public.legal_documents ld + WHERE (ld.kind = 'privacy_policy' AND NEW.raw_user_meta_data->>'agreed_privacy' = 'true') + OR (ld.kind = 'terms_of_service' AND NEW.raw_user_meta_data->>'agreed_terms' = 'true') + OR (ld.kind = 'eula' AND NEW.raw_user_meta_data->>'agreed_eula' = 'true') + ON CONFLICT DO NOTHING; + + RETURN NEW; +END; +$$; + +-- --------------------------------------------------------------------------- +-- D) Backfill — existing users accepted v1 at (approximately) signup time +-- --------------------------------------------------------------------------- +INSERT INTO public.profile_legal_acceptances (user_id, kind, version, accepted_at) +SELECT p.id, ld.kind, ld.current_version, COALESCE(p.created_at, now()) +FROM public.profiles p +CROSS JOIN public.legal_documents ld +ON CONFLICT DO NOTHING; + +COMMIT; + +-- ============================================================================= +-- OPS: publishing an updated document +-- +-- Bump the version of the changed document(s); every user is then gated at +-- their next login / page load until they re-accept. Update the URL too if the +-- document moved. No deploy needed. +-- +-- UPDATE public.legal_documents +-- SET current_version = current_version + 1, +-- effective_at = now(), +-- updated_at = now() +-- WHERE kind = 'terms_of_service'; -- or 'privacy_policy' / 'eula' +-- ============================================================================= diff --git a/supabase/updates/README.md b/supabase/updates/README.md index aad1966..855946c 100644 --- a/supabase/updates/README.md +++ b/supabase/updates/README.md @@ -26,6 +26,9 @@ Full background: [`docs/security-review.md`](../../docs/security-review.md), | `014_profile_preferences.sql` | Creates `profile_preferences` (1-to-1 with `profiles`) and an `auth.users.email` → `profiles.email` sync trigger for the account preferences + verified email-change feature | Before deploying the profile/preferences API release; regenerate `database.types.ts` after | | `015_stripe_billing.sql` | Polar → Stripe billing migration: renames `polar_customer_id`/`polar_subscription_id` to `stripe_customer_id`/`stripe_subscription_id` and clears Polar-era cached rows (zero production customers at migration time) | Before deploying the Stripe API release; regenerate `database.types.ts` after | | `016_report_regeneration_queue.sql` | Creates `cw_report_regeneration_queue` — queue for regenerating report PDFs after note edits (produced by the API, consumed by CW-Reports during its cron runs) | Before deploying the report-notes-edit API release; regenerate `database.types.ts` (api + CropWatch) after | +| `017_line_notifications.sql` | Creates `cw_line_link_nonces` (LINE account-link handshake nonces) + unique partial index on `profiles.line_id` | Before deploying the LINE-linking API release | +| `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 | ## Deploy/run interleaving (critical)