diff --git a/database.types.ts b/database.types.ts index 69f3a1d..c38d706 100644 --- a/database.types.ts +++ b/database.types.ts @@ -2348,6 +2348,32 @@ export type Database = { }, ] } + profile_whats_new_seen: { + Row: { + release: number + seen_at: string + user_id: string + } + Insert: { + release: number + seen_at?: string + user_id: string + } + Update: { + release?: number + seen_at?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "profile_whats_new_seen_user_id_fkey" + columns: ["user_id"] + isOneToOne: true + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + ] + } profiles: { Row: { accepted_agreements: boolean @@ -2827,6 +2853,24 @@ export type Database = { } Relationships: [] } + whats_new: { + Row: { + current_release: number + key: string + published_at: string | null + } + Insert: { + current_release?: number + key: string + published_at?: string | null + } + Update: { + current_release?: number + key?: string + published_at?: string | null + } + Relationships: [] + } } Views: { [_ in never]: never diff --git a/scripts/Update-Legal.sql b/scripts/Update-Legal.sql new file mode 100644 index 0000000..ee9270c --- /dev/null +++ b/scripts/Update-Legal.sql @@ -0,0 +1,3 @@ + 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 diff --git a/src/v1/auth/auth.controller.ts b/src/v1/auth/auth.controller.ts index 2d4227c..858cd7d 100644 --- a/src/v1/auth/auth.controller.ts +++ b/src/v1/auth/auth.controller.ts @@ -242,6 +242,48 @@ export class AuthController { return this.authService.acceptLegal(body, user); } + @Get('whats-new') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Whether the user should see the "What\'s New" dialog', + }) + @ApiOkResponse({ + description: 'Announcement release status returned successfully.', + schema: { type: 'object', additionalProperties: true }, + }) + @ApiUnauthorizedResponse({ + description: 'Missing or invalid bearer token.', + type: ErrorResponseDto, + }) + @ApiInternalServerErrorResponse({ + description: 'Failed to read whats-new status.', + type: ErrorResponseDto, + }) + async getWhatsNewStatus(@CurrentUser() user: AuthenticatedUser) { + return this.authService.getWhatsNewStatus(user); + } + + @Post('whats-new/seen') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: 'Mark the current "What\'s New" release as seen by the user', + }) + @ApiOkResponse({ + description: 'Seen state recorded; fresh status returned.', + schema: { type: 'object', additionalProperties: true }, + }) + @ApiUnauthorizedResponse({ + description: 'Missing or invalid bearer token.', + type: ErrorResponseDto, + }) + @ApiInternalServerErrorResponse({ + description: 'Failed to record whats-new seen state.', + type: ErrorResponseDto, + }) + async markWhatsNewSeen(@CurrentUser() user: AuthenticatedUser) { + return this.authService.markWhatsNewSeen(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 b77da18..f5f80ac 100644 --- a/src/v1/auth/auth.service.ts +++ b/src/v1/auth/auth.service.ts @@ -23,6 +23,14 @@ type ProfileRow = TableRow<'profiles'>; type PreferencesRow = TableRow<'profile_preferences'>; type LegalDocumentRow = TableRow<'legal_documents'>; type LegalAcceptanceRow = TableRow<'profile_legal_acceptances'>; +type WhatsNewRow = TableRow<'whats_new'>; +type WhatsNewSeenRow = TableRow<'profile_whats_new_seen'>; + +export interface WhatsNewStatus { + current_release: number; + seen_release: number | null; + show: boolean; +} export interface LegalDocumentStatus { kind: string; @@ -452,6 +460,85 @@ export class AuthService { return this.getLegalStatus(user); } + /** + * Whether the caller should see the "What's New" dialog: the currently + * published announcement release (whats_new flag row) vs. the release the + * user last dismissed. The release-note content ships inside the app; this + * only decides activation. + */ + async getWhatsNewStatus(user: AuthenticatedUser): Promise { + const client = this.supabaseService.getClient(); + const userId = user.sub; + + const { data: flag, error: flagError } = (await client + .from('whats_new') + .select('current_release') + .eq('key', 'app') + .maybeSingle()) as QueryResult>; + if (flagError) { + throw new InternalServerErrorException( + 'Failed to read whats-new release', + ); + } + + const { data: seen, error: seenError } = (await client + .from('profile_whats_new_seen') + .select('release') + .eq('user_id', userId) + .maybeSingle()) as QueryResult>; + if (seenError) { + throw new InternalServerErrorException( + 'Failed to read whats-new seen state', + ); + } + + const currentRelease = flag?.current_release ?? 0; + const seenRelease = seen?.release ?? null; + return { + current_release: currentRelease, + seen_release: seenRelease, + show: currentRelease > (seenRelease ?? 0), + }; + } + + /** + * Record that the caller has seen the CURRENT announcement release. The + * release is stamped server-side; the client sends nothing. + */ + async markWhatsNewSeen(user: AuthenticatedUser): Promise { + const client = this.supabaseService.getClient(); + const userId = user.sub; + + const { data: flag, error: flagError } = (await client + .from('whats_new') + .select('current_release') + .eq('key', 'app') + .maybeSingle()) as QueryResult>; + if (flagError) { + throw new InternalServerErrorException( + 'Failed to read whats-new release', + ); + } + + const { error: upsertError } = await client + .from('profile_whats_new_seen') + .upsert( + { + user_id: userId, + release: flag?.current_release ?? 0, + seen_at: new Date().toISOString(), + }, + { onConflict: 'user_id' }, + ); + if (upsertError) { + throw new InternalServerErrorException( + 'Failed to record whats-new seen state', + ); + } + + return this.getWhatsNewStatus(user); + } + private readBearerToken(authHeader: string | undefined): string { const rawHeader = authHeader?.trim() ?? ''; const [scheme, token] = rawHeader.split(' '); 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 f63d4cc..96986c9 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 @@ -783,6 +783,12 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot }, }, }, + "/v1/auth/whats-new": { + "get": {}, + }, + "/v1/auth/whats-new/seen": { + "post": {}, + }, "/v1/devices": { "get": { "parameters": [ diff --git a/src/v1/common/v1-route-input-contract.spec.ts b/src/v1/common/v1-route-input-contract.spec.ts index 0ccf704..ccad674 100644 --- a/src/v1/common/v1-route-input-contract.spec.ts +++ b/src/v1/common/v1-route-input-contract.spec.ts @@ -337,7 +337,11 @@ describe('V1 Route Input Contracts', () => { beforeAll(async () => { serviceRegistry = { air: createMockedMethods(['createNote', 'findOne']), - auth: createMockedMethods(['loginWithPassword', 'acceptLegal']), + auth: createMockedMethods([ + 'loginWithPassword', + 'acceptLegal', + 'markWhatsNewSeen', + ]), devices: createMockedMethods([ 'findAll', 'findAllStatus', @@ -482,6 +486,18 @@ describe('V1 Route Input Contracts', () => { kinds: ['terms_of_service', 'eula'], }, }, + { + auth: true, + expectedCall: { + args: [MOCK_USER], + method: 'markWhatsNewSeen', + service: 'auth', + }, + expectedStatus: 201, + method: 'post', + name: 'POST /v1/auth/whats-new/seen takes no body', + url: '/v1/auth/whats-new/seen', + }, { auth: true, expectedCall: { diff --git a/supabase/updates/020_whats_new.sql b/supabase/updates/020_whats_new.sql new file mode 100644 index 0000000..19f30ad --- /dev/null +++ b/supabase/updates/020_whats_new.sql @@ -0,0 +1,124 @@ +-- ============================================================================= +-- 020_whats_new.sql +-- "What's New" announcement flag + per-user seen tracking. +-- +-- A) whats_new — a single-row flag table (key = 'app') holding the currently +-- published announcement release number. The release-note CONTENT ships +-- inside the CropWatch app (i18n keys) together with a matching content +-- release constant; this row only decides when the dialog activates. +-- Publishing = manual UPDATE (see OPS footer), run AFTER the app deploy +-- that contains the matching content. The app shows the dialog only when +-- its content release equals current_release, so a stale deployment (or a +-- premature bump) stays silent instead of showing mismatched notes. +-- +-- B) profile_whats_new_seen — one row per user (upserted, NOT append-only): +-- the release the user last dismissed. The dialog shows once when +-- current_release > seen release, and dismissing records it permanently. +-- Kept off `profiles` (014 rationale) and off `profile_preferences` +-- (that table is display/measurement preferences; this is app state). +-- +-- C) handle_new_user() — full 019 body plus a pre-seed marking brand-new +-- users as having seen the current release: a fresh signup should not get +-- a "what's new" dialog when the whole app is new to them. +-- Run order: 019 before 020 (this REPLACE includes 019's changes). +-- +-- 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) whats_new — single-row announcement flag +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.whats_new ( + key text PRIMARY KEY CHECK (key = 'app'), + current_release integer NOT NULL DEFAULT 0 CHECK (current_release >= 0), + published_at timestamptz +); + +ALTER TABLE public.whats_new ENABLE ROW LEVEL SECURITY; + +-- Release 0 = nothing to announce; the first OPS bump activates the dialog. +INSERT INTO public.whats_new (key, current_release, published_at) +VALUES ('app', 0, NULL) +ON CONFLICT (key) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- B) profile_whats_new_seen — one row per user, upserted on dismiss +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS public.profile_whats_new_seen ( + user_id uuid PRIMARY KEY REFERENCES public.profiles (id) ON DELETE CASCADE, + release integer NOT NULL, + seen_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE public.profile_whats_new_seen ENABLE ROW LEVEL SECURITY; + +-- --------------------------------------------------------------------------- +-- C) handle_new_user() — 019 body + what's-new seen pre-seed for new signups +-- --------------------------------------------------------------------------- +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; + + 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: publishing a "What's New" announcement +-- +-- 1. Ship the app deploy whose release notes (i18n keys) and +-- WHATS_NEW_CONTENT_RELEASE constant describe release . +-- 2. Then activate it; every user sees the dialog once on their next visit: +-- +-- UPDATE public.whats_new +-- SET current_release = , +-- published_at = now() +-- WHERE key = 'app'; +-- ============================================================================= diff --git a/supabase/updates/README.md b/supabase/updates/README.md index 855946c..eece631 100644 --- a/supabase/updates/README.md +++ b/supabase/updates/README.md @@ -29,6 +29,7 @@ Full background: [`docs/security-review.md`](../../docs/security-review.md), | `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 | +| `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 | ## Deploy/run interleaving (critical)