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)
This commit is contained in:
parent
c35b968522
commit
55a80a07ad
1 changed files with 159 additions and 0 deletions
159
app/api/me/notification-subscriptions/route.ts
Normal file
159
app/api/me/notification-subscriptions/route.ts
Normal file
|
|
@ -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<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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue