From 55a80a07ad282ea6e625a9bca267080f9b69f778 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sun, 10 May 2026 07:30:51 -0400 Subject: [PATCH] feat(09-02): GET + PUT /api/me/notification-subscriptions (matrix endpoint) - GET returns { eventKeys, channelTypes, matrix } where matrix defaults to true when no row exists (D-15 opt-out model) - PUT UPSERTs single row via composite PK ON CONFLICT - Validates: event_key (non-empty, <=128 chars), channel_type via isPersonalChannelType, enabled as typeof boolean - Write target always session.user.id (T-09-02-01, T-09-02-03) --- .../me/notification-subscriptions/route.ts | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 app/api/me/notification-subscriptions/route.ts diff --git a/app/api/me/notification-subscriptions/route.ts b/app/api/me/notification-subscriptions/route.ts new file mode 100644 index 0000000..bb00903 --- /dev/null +++ b/app/api/me/notification-subscriptions/route.ts @@ -0,0 +1,159 @@ +// GET /api/me/notification-subscriptions -> { eventKeys, channelTypes, matrix } +// PUT /api/me/notification-subscriptions -> body { event_key, channel_type, enabled } -> { subscription } +// +// SUB-04. Authentication: requireAuth(). Write target is always session.user.id. +// T-09-02-01: no user_id accepted from body or query. +// T-09-02-03: event_key length-bounded; channel_type via isPersonalChannelType; +// enabled typeof === 'boolean'. +// +// GET matrix shape: { event_key → { channel_type → boolean } } +// Default: true when no row exists in user_event_subscriptions (D-15 opt-out model). + +import { NextRequest, NextResponse } from 'next/server'; +import { requireAuth } from '@/lib/auth-utils'; +import { postgresClient } from '@/lib/services/postgres-client'; +import { NotifyEventKey, UserEventSubscription } from '@/lib/types/pipeline'; +import { isPersonalChannelType } from '@/lib/services/personal-channels'; + +export async function GET(): Promise { + const { session, error } = await requireAuth(); + if (error) return error; + + try { + // Query A: all active event keys ordered by sort_order + const eventKeysResult = await postgresClient.query( + `SELECT key, display_label, description, sort_order + FROM notify_event_keys + WHERE is_active = true + ORDER BY sort_order ASC, key ASC`, + [], + ); + + // Query B: channel types configured for this user + const channelTypesResult = await postgresClient.query<{ channel_type: string }>( + `SELECT channel_type + FROM notification_channels + WHERE owner_user_id = $1 AND is_active = true + ORDER BY channel_type ASC`, + [session!.user.id], + ); + + // Query C: stored subscription rows for this user + const subscriptionsResult = await postgresClient.query( + `SELECT event_key, channel_type, enabled + FROM user_event_subscriptions + WHERE user_id = $1`, + [session!.user.id], + ); + + const channelTypes = channelTypesResult.rows.map((r) => r.channel_type); + + // Build stored subscription lookup for O(1) access + const storedMap = new Map(); + for (const row of subscriptionsResult.rows) { + storedMap.set(`${row.event_key}:${row.channel_type}`, row.enabled); + } + + // Build matrix: event_key → channel_type → boolean. + // Default is true (D-15 / T-09-02-03: row absence means enabled). + const matrix: Record> = {}; + for (const ek of eventKeysResult.rows) { + matrix[ek.key] = {}; + for (const ct of channelTypes) { + const stored = storedMap.get(`${ek.key}:${ct}`); + matrix[ek.key][ct] = stored !== undefined ? stored : true; + } + } + + // camelCase transform for event keys + const eventKeys = eventKeysResult.rows.map((r) => ({ + key: r.key, + displayLabel: r.display_label, + description: r.description, + sortOrder: r.sort_order, + })); + + return NextResponse.json({ eventKeys, channelTypes, matrix }); + } catch (e) { + console.error('GET /api/me/notification-subscriptions failed:', e); + return NextResponse.json( + { error: 'Failed to read subscriptions', message: e instanceof Error ? e.message : 'unknown' }, + { status: 500 }, + ); + } +} + +export async function PUT(request: NextRequest): Promise { + const { session, error } = await requireAuth(); + if (error) return error; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: 'Invalid JSON', message: 'Request body must be JSON' }, + { status: 400 }, + ); + } + + if (!body || typeof body !== 'object') { + return NextResponse.json( + { error: 'Invalid body', message: 'Request body must be a JSON object' }, + { status: 400 }, + ); + } + + const { event_key, channel_type, enabled } = body as Record; + + // Validate event_key: non-empty string ≤ 128 chars + if (typeof event_key !== 'string' || event_key.length === 0 || event_key.length > 128) { + return NextResponse.json( + { error: 'Invalid event_key', message: 'event_key must be a non-empty string of at most 128 characters' }, + { status: 400 }, + ); + } + + // Validate channel_type via isPersonalChannelType allowlist + if (!isPersonalChannelType(channel_type)) { + return NextResponse.json( + { error: 'Invalid channel_type', message: 'channel_type must be one of: teams, ntfy' }, + { status: 400 }, + ); + } + + // Validate enabled: must be a boolean + if (typeof enabled !== 'boolean') { + return NextResponse.json( + { error: 'Invalid enabled', message: 'enabled must be a boolean' }, + { status: 400 }, + ); + } + + try { + // Composite PK UPSERT — write target is always session.user.id (T-09-02-01). + const result = await postgresClient.query( + `INSERT INTO user_event_subscriptions (user_id, event_key, channel_type, enabled, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (user_id, event_key, channel_type) DO UPDATE + SET enabled = EXCLUDED.enabled, updated_at = NOW() + RETURNING user_id, event_key, channel_type, enabled, updated_at`, + [session!.user.id, event_key, channel_type, enabled], + ); + + const row = result.rows[0]; + return NextResponse.json({ + subscription: { + eventKey: row.event_key, + channelType: row.channel_type, + enabled: row.enabled, + }, + }); + } catch (e) { + console.error('PUT /api/me/notification-subscriptions failed:', e); + return NextResponse.json( + { error: 'Failed to update subscription', message: e instanceof Error ? e.message : 'unknown' }, + { status: 500 }, + ); + } +}