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:
1 parent
8cedf5614e
commit
f7b3845131
30 files changed
+1734
-10
No files matched your search
@@ -0,0 +1,65 @@
|
||||
// Public REST API — post a reply to a help-center ticket.
|
||||
//
|
||||
// Bearer-authed. POST inserts a reply ({ content }) authored by the current user
|
||||
// into website_help_center_ticket_replies. The target ticket must exist and
|
||||
// belong to the authed user. 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";
|
||||
|
||||
// POST /api/tickets/:id/reply body: { content }
|
||||
export async function POST(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);
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as { content?: unknown };
|
||||
const content = String(body.content ?? "").trim().slice(0, 5000);
|
||||
if (!content) return apiError("Content is required");
|
||||
|
||||
try {
|
||||
// Ownership check — only the ticket owner may reply.
|
||||
const ticket = await prisma.websiteHelpCenterTickets.findUnique({
|
||||
where: { id: ticketId },
|
||||
select: { id: true, userId: true },
|
||||
});
|
||||
if (!ticket || ticket.userId !== uid) return apiError("Ticket not found", 404);
|
||||
|
||||
const now = new Date();
|
||||
const reply = await prisma.websiteHelpCenterTicketReplies.create({
|
||||
data: {
|
||||
ticketId,
|
||||
userId: uid,
|
||||
content,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
select: { id: true, userId: true, content: true, createdAt: true },
|
||||
});
|
||||
|
||||
// Touch the parent ticket so its updatedAt reflects the latest activity.
|
||||
prisma.websiteHelpCenterTickets
|
||||
.update({ where: { id: ticketId }, data: { updatedAt: now }, select: { id: true } })
|
||||
.catch(() => {});
|
||||
|
||||
return apiJson(
|
||||
{
|
||||
reply: {
|
||||
id: reply.id,
|
||||
userId: reply.userId,
|
||||
content: reply.content,
|
||||
createdAt: reply.createdAt,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch {
|
||||
return apiError("Failed to post reply", 503);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Public REST API — help-center tickets (collection).
|
||||
//
|
||||
// Bearer-authed. GET lists the authed user's own tickets; POST opens a new one.
|
||||
// Backed by website_help_center_tickets (WebsiteHelpCenterTickets). Fail-soft:
|
||||
// DB errors return an apiError envelope, 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 — the authed user's tickets (newest first).
|
||||
export async function GET(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
try {
|
||||
const tickets = await prisma.websiteHelpCenterTickets.findMany({
|
||||
where: { userId: uid },
|
||||
select: { id: true, title: true, open: true, createdAt: true },
|
||||
orderBy: { id: "desc" },
|
||||
});
|
||||
|
||||
return apiJson({
|
||||
tickets: tickets.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
open: t.open,
|
||||
createdAt: t.createdAt,
|
||||
})),
|
||||
});
|
||||
} catch {
|
||||
return apiError("Failed to load tickets", 503);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/tickets — open a new ticket ({ title, content, categoryId? }).
|
||||
export async function POST(req: Request) {
|
||||
const uid = await bearerUserId(req);
|
||||
if (!uid) return apiError("Unauthorized", 401);
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as {
|
||||
title?: unknown;
|
||||
content?: unknown;
|
||||
categoryId?: unknown;
|
||||
};
|
||||
|
||||
const title = String(body.title ?? "").trim().slice(0, 255);
|
||||
const content = String(body.content ?? "").trim().slice(0, 5000);
|
||||
if (!title) return apiError("Title is required");
|
||||
if (!content) return apiError("Content is required");
|
||||
|
||||
// categoryId is an optional unsigned BigInt FK — accept a positive numeric
|
||||
// value, otherwise leave it null.
|
||||
let categoryId: bigint | null = null;
|
||||
if (body.categoryId !== undefined && body.categoryId !== null && body.categoryId !== "") {
|
||||
const raw = String(body.categoryId);
|
||||
if (/^\d+$/.test(raw)) categoryId = BigInt(raw);
|
||||
}
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const ticket = await prisma.websiteHelpCenterTickets.create({
|
||||
data: {
|
||||
userId: uid,
|
||||
categoryId,
|
||||
title,
|
||||
content,
|
||||
open: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
select: { id: true, title: true, open: true, createdAt: true },
|
||||
});
|
||||
|
||||
return apiJson(
|
||||
{
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
title: ticket.title,
|
||||
open: ticket.open,
|
||||
createdAt: ticket.createdAt,
|
||||
},
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch {
|
||||
return apiError("Failed to create ticket", 503);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user