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
44 changes: 44 additions & 0 deletions database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions scripts/Update-Legal.sql
Original file line number Diff line number Diff line change
@@ -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'
42 changes: 42 additions & 0 deletions src/v1/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down
87 changes: 87 additions & 0 deletions src/v1/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<WhatsNewStatus> {
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<Pick<WhatsNewRow, 'current_release'>>;
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<Pick<WhatsNewSeenRow, 'release'>>;
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<WhatsNewStatus> {
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<Pick<WhatsNewRow, 'current_release'>>;
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(' ');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
18 changes: 17 additions & 1 deletion src/v1/common/v1-route-input-contract.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,11 @@
beforeAll(async () => {
serviceRegistry = {
air: createMockedMethods(['createNote', 'findOne']),
auth: createMockedMethods(['loginWithPassword', 'acceptLegal']),
auth: createMockedMethods([
'loginWithPassword',
'acceptLegal',
'markWhatsNewSeen',
]),
devices: createMockedMethods([
'findAll',
'findAllStatus',
Expand Down Expand Up @@ -482,6 +486,18 @@
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: {
Expand Down Expand Up @@ -1086,7 +1102,7 @@
];

it.each(successCases)('$name', async (testCase) => {
let req = request(app.getHttpServer())[testCase.method](testCase.url);

Check warning on line 1105 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

Check warning on line 1105 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

if (testCase.auth) {
req = req.set('Authorization', AUTH_HEADER);
Expand Down Expand Up @@ -1124,7 +1140,7 @@
});

it.each(rejectionCases)('$name', async (testCase) => {
let req = request(app.getHttpServer())[testCase.method](testCase.url);

Check warning on line 1143 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

Check warning on line 1143 in src/v1/common/v1-route-input-contract.spec.ts

View workflow job for this annotation

GitHub Actions / build

Unsafe argument of type `any` assigned to a parameter of type `App`

if (testCase.auth) {
req = req.set('Authorization', AUTH_HEADER);
Expand Down
124 changes: 124 additions & 0 deletions supabase/updates/020_whats_new.sql
Original file line number Diff line number Diff line change
@@ -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 <N>.
-- 2. Then activate it; every user sees the dialog once on their next visit:
--
-- UPDATE public.whats_new
-- SET current_release = <N>,
-- published_at = now()
-- WHERE key = 'app';
-- =============================================================================
1 change: 1 addition & 0 deletions supabase/updates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading