wulf-pulse/app/api/me/notification-subscriptions/route.ts
lorentz 55a80a07ad 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)
2026-05-10 07:30:51 -04:00

159 lines
5.6 KiB
TypeScript

// 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<NextResponse> {
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<NotifyEventKey>(
`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<UserEventSubscription>(
`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<string, boolean>();
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<string, Record<string, boolean>> = {};
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<NextResponse> {
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<string, unknown>;
// 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<UserEventSubscription>(
`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 },
);
}
}