mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
225 lines
6.6 KiB
TypeScript
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;
|
|
}
|