Files
2026-06-20 21:07:56 +10:00

466 lines
15 KiB
TypeScript

import { z } from 'zod';
import { AuthError, requireAdmin, requireUser } from '../auth';
import type { Env } from '../env';
import { createNotification } from './api.notifications';
import { validateAlias } from '../lib/aliases';
const jsonHeaders = {
'content-type': 'application/json; charset=utf-8',
};
type LinkType = 'redirect' | 'custom';
type SubmissionStatus = 'pending' | 'approved' | 'rejected' | 'needs_changes';
const ALLOWED_STATUSES: SubmissionStatus[] = ['pending', 'approved', 'rejected', 'needs_changes'];
const createSubmissionSchema = z.object({
privateLinkId: z.string().min(1),
proposedAlias: z.string().min(1).max(100),
note: z.string().optional(),
});
const reviewActionSchema = z.object({
reason: z.string().optional(),
});
type LinkSourceRow = {
id: string;
alias: string;
scope: 'public' | 'private';
link_type: LinkType;
target_url: string | null;
content_markdown: string | null;
description: string | null;
owner_user_id: string | null;
status: string;
};
type SubmissionJoinedRow = {
id: string;
private_link_id: string;
submitted_by_user_id: string;
proposed_alias: string;
note: string | null;
status: SubmissionStatus;
reviewed_by_user_id: string | null;
rejection_reason: string | null;
public_link_id: string | null;
created_at: string;
reviewed_at: string | null;
private_link_alias: string | null;
private_link_target_url: string | null;
link_type: LinkType | null;
content_markdown: string | null;
description: string | null;
};
const SUBMISSION_JOIN_COLUMNS = `s.id, s.private_link_id, s.submitted_by_user_id, s.proposed_alias, s.note, s.status, s.reviewed_by_user_id, s.rejection_reason, s.public_link_id, s.created_at, s.reviewed_at, l.alias AS private_link_alias, l.target_url AS private_link_target_url`;
const SUBMISSION_BY_ID_QUERY = `SELECT ${SUBMISSION_JOIN_COLUMNS}, l.link_type, l.content_markdown, l.description
FROM promotion_submissions s
LEFT JOIN links l ON l.id = s.private_link_id
WHERE s.id=?
LIMIT 1`;
const PENDING_DUPLICATE_QUERY = `SELECT id FROM promotion_submissions
WHERE private_link_id=? AND submitted_by_user_id=? AND proposed_alias=? AND status='pending'
LIMIT 1`;
const PRIVATE_LINK_OWNERSHIP_QUERY = `SELECT id, alias, scope, link_type, target_url, content_markdown, description, owner_user_id, status
FROM links
WHERE id=? AND scope='private' AND owner_user_id=? AND status='active'
LIMIT 1`;
const PUBLIC_ALIAS_CONFLICT_QUERY = `SELECT id FROM links
WHERE scope='public' AND status!='deleted' AND alias=?
LIMIT 1`;
const MINE_LIST_QUERY = `SELECT ${SUBMISSION_JOIN_COLUMNS}
FROM promotion_submissions s
LEFT JOIN links l ON l.id = s.private_link_id
WHERE s.submitted_by_user_id=?
ORDER BY s.created_at DESC`;
const ADMIN_LIST_QUERY = `SELECT ${SUBMISSION_JOIN_COLUMNS}
FROM promotion_submissions s
LEFT JOIN links l ON l.id = s.private_link_id
WHERE s.status=?
ORDER BY s.created_at DESC`;
const INSERT_SUBMISSION = `INSERT INTO promotion_submissions (id, private_link_id, submitted_by_user_id, proposed_alias, note, status)
VALUES (?, ?, ?, ?, ?, 'pending')`;
const INSERT_PUBLIC_LINK = `INSERT INTO links (id, scope, owner_user_id, alias, link_type, target_url, content_markdown, description, status, click_count)
VALUES (?, 'public', NULL, ?, ?, ?, ?, ?, 'active', 0)`;
const UPDATE_APPROVED = `UPDATE promotion_submissions
SET status='approved', reviewed_by_user_id=?, reviewed_at=?, public_link_id=?
WHERE id=? AND status='pending'`;
const UPDATE_REJECTED = `UPDATE promotion_submissions
SET status='rejected', reviewed_by_user_id=?, reviewed_at=?, rejection_reason=?
WHERE id=? AND status='pending'`;
const UPDATE_NEEDS_CHANGES = `UPDATE promotion_submissions
SET status='needs_changes', reviewed_by_user_id=?, reviewed_at=?, rejection_reason=?
WHERE id=? AND status='pending'`;
export async function handlePromotionsApi(request: Request, env: Env): Promise<Response | null> {
const url = new URL(request.url);
const pathname = url.pathname;
try {
if (pathname === '/api/promotions') {
if (request.method === 'POST') {
return await createSubmission(request, env);
}
return methodNotAllowed();
}
if (pathname === '/api/promotions/mine') {
if (request.method === 'GET') {
return await listMySubmissions(request, env);
}
return methodNotAllowed();
}
if (pathname === '/api/admin/promotions') {
if (request.method === 'GET') {
return await listAdminSubmissions(request, env, url);
}
return methodNotAllowed();
}
const approveMatch = pathname.match(/^\/api\/admin\/promotions\/([^/]+)\/approve$/);
if (approveMatch) {
const id = decodePathSegment(approveMatch[1]);
if (!id) {
return json({ error: 'Invalid submission id' }, { status: 400 });
}
if (request.method === 'POST') {
return await approveSubmission(request, env, id);
}
return methodNotAllowed();
}
const rejectMatch = pathname.match(/^\/api\/admin\/promotions\/([^/]+)\/reject$/);
if (rejectMatch) {
const id = decodePathSegment(rejectMatch[1]);
if (!id) {
return json({ error: 'Invalid submission id' }, { status: 400 });
}
if (request.method === 'POST') {
return await rejectSubmission(request, env, id);
}
return methodNotAllowed();
}
const needsChangesMatch = pathname.match(/^\/api\/admin\/promotions\/([^/]+)\/needs-changes$/);
if (needsChangesMatch) {
const id = decodePathSegment(needsChangesMatch[1]);
if (!id) {
return json({ error: 'Invalid submission id' }, { status: 400 });
}
if (request.method === 'POST') {
return await needsChangesSubmission(request, env, id);
}
return methodNotAllowed();
}
return null;
} catch (error) {
const response = promotionApiErrorResponse(error);
if (response) {
return response;
}
throw error;
}
}
async function createSubmission(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const input = await readCreateInput(request);
const privateLink = await env.DB.prepare(PRIVATE_LINK_OWNERSHIP_QUERY)
.bind(input.privateLinkId, user.id)
.first<LinkSourceRow>();
if (!privateLink) {
return json({ error: 'Link not found' }, { status: 404 });
}
const duplicate = await env.DB.prepare(PENDING_DUPLICATE_QUERY)
.bind(input.privateLinkId, user.id, input.proposedAlias)
.first<{ id: string }>();
if (duplicate) {
return json({ error: 'A pending submission for this link and alias already exists' }, { status: 409 });
}
const id = crypto.randomUUID();
await env.DB.prepare(INSERT_SUBMISSION)
.bind(id, input.privateLinkId, user.id, input.proposedAlias, input.note ?? null)
.run();
const submission = await env.DB.prepare(SUBMISSION_BY_ID_QUERY).bind(id).first<SubmissionJoinedRow>();
return json({ submission: toSubmissionJson(submission!) }, { status: 201 });
}
async function listMySubmissions(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const result = await env.DB.prepare(MINE_LIST_QUERY).bind(user.id).all<SubmissionJoinedRow>();
return json({ submissions: (result.results ?? []).map(toSubmissionJson) });
}
async function listAdminSubmissions(request: Request, env: Env, url: URL): Promise<Response> {
await requireAdmin(request, env);
const statusParam = url.searchParams.get('status') ?? 'pending';
if (!ALLOWED_STATUSES.includes(statusParam as SubmissionStatus)) {
return json({ error: 'Invalid status filter' }, { status: 400 });
}
const result = await env.DB.prepare(ADMIN_LIST_QUERY)
.bind(statusParam)
.all<SubmissionJoinedRow>();
return json({ submissions: (result.results ?? []).map(toSubmissionJson) });
}
async function approveSubmission(request: Request, env: Env, id: string): Promise<Response> {
const admin = await requireAdmin(request, env);
const submission = await env.DB.prepare(SUBMISSION_BY_ID_QUERY).bind(id).first<SubmissionJoinedRow>();
if (!submission) {
return json({ error: 'Submission not found' }, { status: 404 });
}
if (submission.status !== 'pending') {
return json({ error: 'Submission is not pending' }, { status: 409 });
}
const conflict = await env.DB.prepare(PUBLIC_ALIAS_CONFLICT_QUERY)
.bind(submission.proposed_alias)
.first<{ id: string }>();
if (conflict) {
return json({ error: 'Alias already exists' }, { status: 409 });
}
const publicLinkId = crypto.randomUUID();
const now = new Date().toISOString();
await env.DB.prepare(INSERT_PUBLIC_LINK)
.bind(
publicLinkId,
submission.proposed_alias,
submission.link_type,
submission.private_link_target_url,
submission.content_markdown,
submission.description,
)
.run();
await env.DB.prepare(UPDATE_APPROVED).bind(admin.id, now, publicLinkId, id).run();
await createNotification(
env,
submission.submitted_by_user_id,
'promotion_approved',
'Promotion approved',
`Your private link '${submission.private_link_alias ?? submission.proposed_alias}' was approved and published as '${submission.proposed_alias}'.`,
submission.id,
);
const publicLink = {
id: publicLinkId,
alias: submission.proposed_alias,
scope: 'public' as const,
linkType: submission.link_type,
targetUrl: submission.private_link_target_url,
contentMarkdown: submission.content_markdown,
description: submission.description,
ownerUserId: null,
clickCount: 0,
status: 'active' as const,
createdAt: now,
updatedAt: now,
};
const updatedSubmission = toSubmissionJson({
...submission,
status: 'approved',
reviewed_by_user_id: admin.id,
reviewed_at: now,
public_link_id: publicLinkId,
});
return json({ submission: updatedSubmission, publicLink });
}
async function rejectSubmission(request: Request, env: Env, id: string): Promise<Response> {
const admin = await requireAdmin(request, env);
const { reason } = await readReviewInput(request);
const submission = await env.DB.prepare(SUBMISSION_BY_ID_QUERY).bind(id).first<SubmissionJoinedRow>();
if (!submission) {
return json({ error: 'Submission not found' }, { status: 404 });
}
if (submission.status !== 'pending') {
return json({ error: 'Submission is not pending' }, { status: 409 });
}
const now = new Date().toISOString();
await env.DB.prepare(UPDATE_REJECTED).bind(admin.id, now, reason ?? null, id).run();
await createNotification(
env,
submission.submitted_by_user_id,
'promotion_rejected',
'Promotion rejected',
`Your promotion request for '${submission.private_link_alias ?? submission.proposed_alias}' was rejected${reason ? `: ${reason}` : '.'}`,
submission.id,
);
return json({
submission: toSubmissionJson({
...submission,
status: 'rejected',
reviewed_by_user_id: admin.id,
reviewed_at: now,
rejection_reason: reason ?? null,
}),
});
}
async function needsChangesSubmission(request: Request, env: Env, id: string): Promise<Response> {
const admin = await requireAdmin(request, env);
const { reason } = await readReviewInput(request);
const submission = await env.DB.prepare(SUBMISSION_BY_ID_QUERY).bind(id).first<SubmissionJoinedRow>();
if (!submission) {
return json({ error: 'Submission not found' }, { status: 404 });
}
if (submission.status !== 'pending') {
return json({ error: 'Submission is not pending' }, { status: 409 });
}
const now = new Date().toISOString();
await env.DB.prepare(UPDATE_NEEDS_CHANGES).bind(admin.id, now, reason ?? null, id).run();
await createNotification(
env,
submission.submitted_by_user_id,
'promotion_needs_changes',
'Promotion needs changes',
`Your promotion request for '${submission.private_link_alias ?? submission.proposed_alias}' needs changes${reason ? `: ${reason}` : '.'}`,
submission.id,
);
return json({
submission: toSubmissionJson({
...submission,
status: 'needs_changes',
reviewed_by_user_id: admin.id,
reviewed_at: now,
rejection_reason: reason ?? null,
}),
});
}
async function readCreateInput(request: Request): Promise<{ privateLinkId: string; proposedAlias: string; note: string | null }> {
let body: unknown;
try {
body = await request.json();
} catch {
throw new RequestValidationError('Invalid JSON body');
}
const parsed = createSubmissionSchema.safeParse(body);
if (!parsed.success) {
throw new RequestValidationError(parsed.error.issues[0]?.message ?? 'Invalid request body');
}
const aliasValidation = validateAlias(parsed.data.proposedAlias);
if (!aliasValidation.ok) {
throw new RequestValidationError(aliasValidation.error);
}
return {
privateLinkId: parsed.data.privateLinkId,
proposedAlias: aliasValidation.value,
note: parsed.data.note ?? null,
};
}
async function readReviewInput(request: Request): Promise<{ reason: string | null }> {
let body: unknown;
try {
body = await request.json();
} catch {
return { reason: null };
}
const parsed = reviewActionSchema.safeParse(body);
if (!parsed.success) {
throw new RequestValidationError(parsed.error.issues[0]?.message ?? 'Invalid request body');
}
return { reason: parsed.data.reason ?? null };
}
function toSubmissionJson(row: SubmissionJoinedRow) {
return {
id: row.id,
privateLinkId: row.private_link_id,
submittedByUserId: row.submitted_by_user_id,
proposedAlias: row.proposed_alias,
note: row.note,
status: row.status,
reviewedByUserId: row.reviewed_by_user_id,
rejectionReason: row.rejection_reason,
publicLinkId: row.public_link_id,
createdAt: row.created_at,
reviewedAt: row.reviewed_at,
privateLinkAlias: row.private_link_alias,
privateLinkTargetUrl: row.private_link_target_url,
};
}
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 promotionApiErrorResponse(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 });
}
if (isUniqueConstraintError(error)) {
return json({ error: 'Alias already exists' }, { status: 409 });
}
return null;
}
function isUniqueConstraintError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
return error.message.toLowerCase().includes('unique constraint failed');
}