Files
heygo/worker/routes/api.notifications.ts
2026-06-20 21:07:56 +10:00

225 lines
6.6 KiB
TypeScript

import { z } from 'zod';
import { AuthError, requireUser } from '../auth';
import type { Env } from '../env';
const jsonHeaders = {
'content-type': 'application/json; charset=utf-8',
};
type NotificationStatus = 'unread' | 'viewed';
type NotificationJoinedRow = {
id: string;
user_id: string;
title: string;
body: string | null;
kind: string;
related_submission_id: string | null;
status: NotificationStatus;
created_at: string;
viewed_at: string | null;
proposed_alias: string | null;
private_link_id: string | null;
private_link_alias: string | null;
};
const NOTIFICATION_LIST_QUERY = `SELECT n.id, n.user_id, n.title, n.body, n.kind, n.related_submission_id, n.status, n.created_at, n.viewed_at,
s.proposed_alias, s.private_link_id, l.alias AS private_link_alias
FROM notifications n
LEFT JOIN promotion_submissions s ON s.id = n.related_submission_id
LEFT JOIN links l ON l.id = s.private_link_id
WHERE n.user_id=?
ORDER BY n.created_at DESC
LIMIT 200`;
const NOTIFICATION_BY_ID_QUERY = `SELECT id, user_id, status FROM notifications WHERE id=? LIMIT 1`;
const UNREAD_COUNT_QUERY = `SELECT COUNT(*) AS count FROM notifications WHERE user_id=? AND status='unread'`;
const MARK_VIEWED_QUERY = `UPDATE notifications SET status='viewed', viewed_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE user_id=? AND status='unread'`;
const DELETE_BY_ID_QUERY = `DELETE FROM notifications WHERE id=? AND user_id=?`;
const deleteBatchSchema = z.object({
ids: z.array(z.string().min(1)).min(1).max(200),
});
export async function handleNotificationsApi(request: Request, env: Env): Promise<Response | null> {
const url = new URL(request.url);
const pathname = url.pathname;
try {
if (pathname === '/api/notifications') {
if (request.method === 'GET') {
return await listNotifications(request, env);
}
return methodNotAllowed();
}
if (pathname === '/api/notifications/unread-count') {
if (request.method === 'GET') {
return await getUnreadCount(request, env);
}
return methodNotAllowed();
}
if (pathname === '/api/notifications/delete-batch') {
if (request.method === 'POST') {
return await deleteBatch(request, env);
}
return methodNotAllowed();
}
const singleMatch = pathname.match(/^\/api\/notifications\/([^/]+)$/);
if (singleMatch) {
const id = decodePathSegment(singleMatch[1]);
if (!id) {
return json({ error: 'Invalid notification id' }, { status: 400 });
}
if (request.method === 'DELETE') {
return await deleteOne(request, env, id);
}
return methodNotAllowed();
}
return null;
} catch (error) {
const response = notificationApiErrorResponse(error);
if (response) {
return response;
}
throw error;
}
}
async function listNotifications(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const result = await env.DB.prepare(NOTIFICATION_LIST_QUERY).bind(user.id).all<NotificationJoinedRow>();
const rows = result.results ?? [];
if (rows.some((row) => row.status === 'unread')) {
try {
await env.DB.prepare(MARK_VIEWED_QUERY).bind(user.id).run();
} catch {
// Best-effort: the list is still returned even if marking viewed fails.
}
}
return json({ notifications: rows.map(toNotificationJson) });
}
async function getUnreadCount(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const row = await env.DB.prepare(UNREAD_COUNT_QUERY).bind(user.id).first<{ count: number }>();
return json({ count: row?.count ?? 0 });
}
async function deleteOne(request: Request, env: Env, id: string): Promise<Response> {
const user = await requireUser(request, env);
const existing = await env.DB.prepare(NOTIFICATION_BY_ID_QUERY).bind(id).first<{ id: string; user_id: string }>();
if (!existing) {
return json({ error: 'Notification not found' }, { status: 404 });
}
if (existing.user_id !== user.id) {
return json({ error: 'Notification not found' }, { status: 404 });
}
await env.DB.prepare(DELETE_BY_ID_QUERY).bind(id, user.id).run();
return json({ ok: true });
}
async function deleteBatch(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
let body: unknown;
try {
body = await request.json();
} catch {
throw new RequestValidationError('Invalid JSON body');
}
const parsed = deleteBatchSchema.safeParse(body);
if (!parsed.success) {
throw new RequestValidationError(parsed.error.issues[0]?.message ?? 'Invalid request body');
}
const ids = parsed.data.ids;
let deleted = 0;
for (const id of ids) {
const result = await env.DB.prepare(DELETE_BY_ID_QUERY).bind(id, user.id).run();
deleted += (result.meta?.changes ?? 0);
}
return json({ ok: true, deleted });
}
export async function createNotification(
env: Env,
userId: string,
kind: string,
title: string,
body: string,
relatedSubmissionId: string | null,
): Promise<void> {
try {
await env.DB.prepare(
`INSERT INTO notifications (id, user_id, title, body, kind, related_submission_id, status)
VALUES (?, ?, ?, ?, ?, ?, 'unread')`,
)
.bind(crypto.randomUUID(), userId, title, body, kind, relatedSubmissionId)
.run();
} catch {
// Best-effort: never block a review action because of a notification failure.
}
}
function toNotificationJson(row: NotificationJoinedRow) {
return {
id: row.id,
title: row.title,
body: row.body,
kind: row.kind,
status: row.status,
relatedSubmissionId: row.related_submission_id,
proposedAlias: row.proposed_alias,
privateLinkId: row.private_link_id,
privateLinkAlias: row.private_link_alias,
createdAt: row.created_at,
viewedAt: row.viewed_at,
};
}
class RequestValidationError extends Error {}
function decodePathSegment(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
function json(body: unknown, init: ResponseInit = {}): Response {
return Response.json(body, {
...init,
headers: {
...jsonHeaders,
...init.headers,
},
});
}
function methodNotAllowed(): Response {
return json({ error: 'Method not allowed' }, { status: 405 });
}
function notificationApiErrorResponse(error: unknown): Response | null {
if (error instanceof RequestValidationError) {
return json({ error: error.message || 'Invalid request body' }, { status: 400 });
}
if (error instanceof AuthError) {
return json({ error: error.message }, { status: error.status });
}
return null;
}