- 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)
159 lines
5.6 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|