mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
466 lines
15 KiB
TypeScript
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');
|
|
}
|