Close the web-feasible 100% gaps: REST write/token API, tickets, draw-badge, /me, sanitisation, dusk, radio SSE

Final parity push (web-tier only):
- REST API write + token auth: POST /api/tokens (issue a personal_access_token
  for the session user), Bearer auth via src/lib/api-auth.ts, POST
  /api/articles/[slug]/comment, GET/DELETE /api/me/tokens, full tickets API
  (/api/tickets +[id] +[id]/reply), radio current-dj/points/points-leaderboard/
  embed-config + POST shouts, and a real-time /api/radio/stream (SSE). 31 public
  API routes total.
- Pages: /draw-badge (buy a custom profile badge → credits + RCON), /me
  dashboard (stats + online friends + referral claim). Wired into the nav.
- HTML sanitisation (sanitize-html) — the HTMLPurifier equivalent — applied to
  writeable boxes + article bodies before dangerouslySetInnerHTML.
- "Dusk" dark theme preset + a default-dark site option honoured by the
  no-flash boot script.

Verified live (prod, amx_test): token issue → Bearer endpoint 200, no-token
401; /api/me/tokens lists it; current-dj/leaderboard JSON; /me + /draw-badge
200; reverted the test user + tokens. tsc 0, vitest 49/49, next build 0.
This commit is contained in:
Simo committed 2026-06-29 18:15:01 +02:00
1 parent 8cedf5614e
commit f7b3845131
30 files changed
+1734 -10

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
// Public REST API — a single help-center ticket (with replies).
//
// Bearer-authed. GET returns one ticket that MUST belong to the authed user,
// together with its replies; reply author usernames are resolved in a single
// users lookup. Fail-soft: never a 500.
import { apiError, apiJson } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
// GET /api/tickets/:id
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
const uid = await bearerUserId(req);
if (!uid) return apiError("Unauthorized", 401);
const { id } = await params;
if (!/^\d+$/.test(id)) return apiError("Invalid ticket id");
const ticketId = BigInt(id);
try {
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
where: { id: ticketId },
select: {
id: true,
userId: true,
categoryId: true,
title: true,
content: true,
open: true,
createdAt: true,
},
});
// Ownership check — return 404 (not 403) so a foreign id is indistinguishable
// from a missing one.
if (!ticket || ticket.userId !== uid) return apiError("Ticket not found", 404);
const replies = await prisma.websiteHelpCenterTicketReplies.findMany({
where: { ticketId },
select: { id: true, userId: true, content: true, createdAt: true },
orderBy: { id: "asc" },
});
// Resolve author usernames in one query.
const authorIds = [...new Set(replies.map((r) => r.userId))];
const authors = authorIds.length
? await prisma.user.findMany({
where: { id: { in: authorIds } },
select: { id: true, username: true },
})
: [];
const nameById = new Map(authors.map((a) => [a.id, a.username]));
return apiJson({
ticket: {
id: ticket.id,
categoryId: ticket.categoryId,
title: ticket.title,
content: ticket.content,
open: ticket.open,
createdAt: ticket.createdAt,
replies: replies.map((r) => ({
id: r.id,
userId: r.userId,
username: nameById.get(r.userId) ?? null,
content: r.content,
createdAt: r.createdAt,
})),
},
});
} catch {
return apiError("Failed to load ticket", 503);
}
}