388 lines
13 KiB
TypeScript
388 lines
13 KiB
TypeScript
import type { SessionSnapshot } from "../shared/types";
|
|
import type { BunRequest } from "bun";
|
|
import {
|
|
applySnapshot,
|
|
claimTakeover,
|
|
joinSession,
|
|
requestTakeover,
|
|
requestTakeoverByToken,
|
|
snapshotSession,
|
|
} from "./domain";
|
|
import {
|
|
createSession,
|
|
createTestPreview,
|
|
createTestSession,
|
|
getSession,
|
|
getSessionByCode,
|
|
isTestSessionCode,
|
|
} from "./store";
|
|
import { broadcastSessionState, startTestSimulation } from "./websocket";
|
|
import { registerPushToken } from "./notifications";
|
|
|
|
function jsonResponse(data: unknown, status = 200): Response {
|
|
return Response.json(data, { status });
|
|
}
|
|
|
|
async function readJson<T>(req: Request): Promise<T> {
|
|
return (await req.json()) as T;
|
|
}
|
|
|
|
function clampLine(value: string, maxLength: number): string {
|
|
return value.trim().replace(/\s+/g, " ").slice(0, maxLength);
|
|
}
|
|
|
|
function clampMessage(value: string, maxLength: number): string {
|
|
return value.trim().slice(0, maxLength);
|
|
}
|
|
|
|
function isValidEmail(value: string): boolean {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
|
}
|
|
|
|
function previewSession(session: SessionSnapshot) {
|
|
return {
|
|
sessionId: session.id,
|
|
code: session.code,
|
|
status: session.status,
|
|
players: session.players.map((player) => ({
|
|
id: player.id,
|
|
name: player.name,
|
|
role: player.role,
|
|
isDummy: player.isDummy,
|
|
connected: player.connected,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function readBankerId(req: Request): string | null {
|
|
const url = new URL(req.url);
|
|
return url.searchParams.get("bankerId");
|
|
}
|
|
|
|
function isSnapshotCandidate(value: unknown): value is SessionSnapshot {
|
|
if (!value || typeof value !== "object") return false;
|
|
const snapshot = value as SessionSnapshot;
|
|
return (
|
|
typeof snapshot.id === "string" &&
|
|
typeof snapshot.code === "string" &&
|
|
typeof snapshot.status === "string" &&
|
|
typeof snapshot.createdAt === "number" &&
|
|
typeof snapshot.bankerId === "string" &&
|
|
typeof snapshot.blackoutActive === "boolean" &&
|
|
Array.isArray(snapshot.players) &&
|
|
Array.isArray(snapshot.transactions) &&
|
|
Array.isArray(snapshot.chats) &&
|
|
Array.isArray(snapshot.groups) &&
|
|
Array.isArray(snapshot.takeoverRequests)
|
|
);
|
|
}
|
|
|
|
export const apiRoutes = {
|
|
"/api/health": {
|
|
GET() {
|
|
return jsonResponse({ ok: true });
|
|
},
|
|
},
|
|
"/api/support": {
|
|
async POST(req: BunRequest) {
|
|
let body: { name?: string; email?: string; message?: string };
|
|
try {
|
|
body = await readJson<{ name?: string; email?: string; message?: string }>(req);
|
|
} catch {
|
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
|
}
|
|
|
|
const name = clampLine(body.name ?? "", 80);
|
|
const email = clampLine(body.email ?? "", 120);
|
|
const message = clampMessage(body.message ?? "", 2000);
|
|
|
|
if (!name || !email || !message) {
|
|
return jsonResponse({ message: "Name, email, and message are required" }, 400);
|
|
}
|
|
if (!isValidEmail(email)) {
|
|
return jsonResponse({ message: "Invalid email address" }, 400);
|
|
}
|
|
|
|
const webhookUrl = process.env.DISCORD_SUPPORT_WEBHOOK_URL;
|
|
if (!webhookUrl) {
|
|
return jsonResponse({ message: "Support webhook not configured" }, 500);
|
|
}
|
|
|
|
const userAgent = (req.headers.get("user-agent") ?? "unknown").slice(0, 180);
|
|
const payload = {
|
|
username: "Negopoly Support",
|
|
embeds: [
|
|
{
|
|
title: "New support request",
|
|
color: 0xe39b43,
|
|
fields: [
|
|
{ name: "Name", value: name, inline: true },
|
|
{ name: "Email", value: email, inline: true },
|
|
],
|
|
description: message,
|
|
footer: { text: `User agent: ${userAgent}` },
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
],
|
|
};
|
|
|
|
const response = await fetch(webhookUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => "");
|
|
console.error("Support webhook failed:", response.status, errorText);
|
|
return jsonResponse({ message: "Failed to forward support request" }, 502);
|
|
}
|
|
|
|
return jsonResponse({ ok: true });
|
|
},
|
|
},
|
|
"/api/session": {
|
|
async POST(req: BunRequest) {
|
|
let body: { bankerName?: string };
|
|
try {
|
|
body = await readJson<{ bankerName?: string }>(req);
|
|
} catch {
|
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
|
}
|
|
const bankerName = body.bankerName?.trim() || "Banker";
|
|
const { session, banker } = createSession(bankerName);
|
|
return jsonResponse({
|
|
sessionId: session.id,
|
|
sessionCode: session.code,
|
|
playerId: banker.id,
|
|
role: banker.role,
|
|
status: session.status,
|
|
});
|
|
},
|
|
},
|
|
"/api/session/:code/join": {
|
|
async POST(req: BunRequest) {
|
|
const code = req.params.code;
|
|
let body: { name?: string; playerId?: string };
|
|
try {
|
|
body = await readJson<{ name?: string; playerId?: string }>(req);
|
|
} catch {
|
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
|
}
|
|
if (isTestSessionCode(code)) {
|
|
const session = createTestSession();
|
|
const player = joinSession(session, body.name ?? "Player", body.playerId);
|
|
startTestSimulation(session.id);
|
|
return jsonResponse({
|
|
sessionId: session.id,
|
|
sessionCode: session.code,
|
|
playerId: player.id,
|
|
role: player.role,
|
|
status: session.status,
|
|
});
|
|
}
|
|
const session = getSessionByCode(code) ?? getSession(code);
|
|
if (!session) {
|
|
return jsonResponse({ message: "Session not found" }, 404);
|
|
}
|
|
if (session.status === "ended") {
|
|
return jsonResponse({ message: "Session has ended" }, 400);
|
|
}
|
|
const player = joinSession(session, body.name ?? "Player", body.playerId);
|
|
return jsonResponse({
|
|
sessionId: session.id,
|
|
sessionCode: session.code,
|
|
playerId: player.id,
|
|
role: player.role,
|
|
status: session.status,
|
|
});
|
|
},
|
|
},
|
|
"/api/session/:id": {
|
|
GET(req: BunRequest) {
|
|
const session = getSession(req.params.id);
|
|
if (!session) {
|
|
return jsonResponse({ message: "Session not found" }, 404);
|
|
}
|
|
const snapshot: SessionSnapshot = snapshotSession(session);
|
|
return jsonResponse(snapshot);
|
|
},
|
|
},
|
|
"/api/session/:id/state": {
|
|
GET(req: BunRequest) {
|
|
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
|
|
if (!session) {
|
|
return jsonResponse({ message: "Session not found" }, 404);
|
|
}
|
|
const bankerId = readBankerId(req);
|
|
if (!bankerId || bankerId !== session.bankerId) {
|
|
return jsonResponse({ message: "Not authorized" }, 403);
|
|
}
|
|
const snapshot: SessionSnapshot = snapshotSession(session);
|
|
return jsonResponse(snapshot);
|
|
},
|
|
async POST(req: BunRequest) {
|
|
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
|
|
if (!session) {
|
|
return jsonResponse({ message: "Session not found" }, 404);
|
|
}
|
|
let body: Record<string, unknown>;
|
|
try {
|
|
body = await readJson<Record<string, unknown>>(req);
|
|
} catch {
|
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
|
}
|
|
const bankerId = (typeof body.bankerId === "string" ? body.bankerId : null) ?? readBankerId(req);
|
|
if (!bankerId || bankerId !== session.bankerId) {
|
|
return jsonResponse({ message: "Not authorized" }, 403);
|
|
}
|
|
|
|
const candidate = (body.state ?? body) as unknown;
|
|
if (!isSnapshotCandidate(candidate)) {
|
|
return jsonResponse({ message: "Invalid session snapshot" }, 400);
|
|
}
|
|
|
|
applySnapshot(session, candidate);
|
|
broadcastSessionState(session.id);
|
|
return jsonResponse({ ok: true });
|
|
},
|
|
},
|
|
"/api/session/:code/info": {
|
|
GET(req: BunRequest) {
|
|
const code = req.params.code;
|
|
if (isTestSessionCode(code)) {
|
|
const snapshot = createTestPreview();
|
|
return jsonResponse(previewSession(snapshot));
|
|
}
|
|
const session = getSessionByCode(code) ?? getSession(code);
|
|
if (!session) {
|
|
return jsonResponse({ message: "Session not found" }, 404);
|
|
}
|
|
const snapshot: SessionSnapshot = snapshotSession(session);
|
|
return jsonResponse(previewSession(snapshot));
|
|
},
|
|
},
|
|
"/api/session/:id/takeover": {
|
|
async POST(req: BunRequest) {
|
|
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
|
|
if (!session) {
|
|
return jsonResponse({ message: "Session not found" }, 404);
|
|
}
|
|
let body: { playerId?: string; dummyId?: string };
|
|
try {
|
|
body = await readJson<{ playerId?: string; dummyId?: string }>(req);
|
|
} catch {
|
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
|
}
|
|
const playerId = body.playerId?.trim();
|
|
const dummyId = body.dummyId?.trim();
|
|
if (!playerId || !dummyId) {
|
|
return jsonResponse({ message: "Missing playerId or dummyId" }, 400);
|
|
}
|
|
try {
|
|
requestTakeover(session, playerId, dummyId);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unable to request takeover";
|
|
return jsonResponse({ message }, 400);
|
|
}
|
|
broadcastSessionState(session.id);
|
|
return jsonResponse({ ok: true });
|
|
},
|
|
},
|
|
"/api/session/:id/takeover-request": {
|
|
async POST(req: BunRequest) {
|
|
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
|
|
if (!session) {
|
|
return jsonResponse({ message: "Session not found" }, 404);
|
|
}
|
|
let body: { dummyId?: string; name?: string };
|
|
try {
|
|
body = await readJson<{ dummyId?: string; name?: string }>(req);
|
|
} catch {
|
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
|
}
|
|
const dummyId = body.dummyId?.trim();
|
|
if (!dummyId) {
|
|
return jsonResponse({ message: "Missing dummyId" }, 400);
|
|
}
|
|
try {
|
|
const token = requestTakeoverByToken(session, dummyId, body.name);
|
|
broadcastSessionState(session.id);
|
|
return jsonResponse({ token });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unable to request takeover";
|
|
return jsonResponse({ message }, 400);
|
|
}
|
|
},
|
|
},
|
|
"/api/session/:id/takeover-claim": {
|
|
async POST(req: BunRequest) {
|
|
const session = getSession(req.params.id) ?? getSessionByCode(req.params.id);
|
|
if (!session) {
|
|
return jsonResponse({ message: "Session not found" }, 404);
|
|
}
|
|
let body: { token?: string };
|
|
try {
|
|
body = await readJson<{ token?: string }>(req);
|
|
} catch {
|
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
|
}
|
|
const token = body.token?.trim();
|
|
if (!token) {
|
|
return jsonResponse({ message: "Missing token" }, 400);
|
|
}
|
|
try {
|
|
const playerId = claimTakeover(session, token);
|
|
broadcastSessionState(session.id);
|
|
return jsonResponse({
|
|
sessionId: session.id,
|
|
sessionCode: session.code,
|
|
playerId,
|
|
role: "player",
|
|
status: session.status,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Unable to claim takeover";
|
|
if (message === "Takeover not approved") {
|
|
return jsonResponse({ message }, 409);
|
|
}
|
|
return jsonResponse({ message }, 400);
|
|
}
|
|
},
|
|
},
|
|
"/api/push/register": {
|
|
async POST(req: BunRequest) {
|
|
let body: { sessionId?: string; playerId?: string; token?: string; platform?: string };
|
|
try {
|
|
body = await readJson<{
|
|
sessionId?: string;
|
|
playerId?: string;
|
|
token?: string;
|
|
platform?: string;
|
|
}>(req);
|
|
} catch {
|
|
return jsonResponse({ message: "Invalid request body" }, 400);
|
|
}
|
|
const sessionId = body.sessionId?.trim();
|
|
const playerId = body.playerId?.trim();
|
|
const token = body.token?.trim();
|
|
const platform = body.platform?.trim();
|
|
if (!sessionId || !playerId || !token || !platform) {
|
|
return jsonResponse({ message: "Missing sessionId, playerId, token, or platform" }, 400);
|
|
}
|
|
if (platform !== "ios" && platform !== "android") {
|
|
return jsonResponse({ message: "Invalid platform" }, 400);
|
|
}
|
|
const session = getSession(sessionId);
|
|
if (!session) {
|
|
return jsonResponse({ message: "Session not found" }, 404);
|
|
}
|
|
const player = session.players.get(playerId);
|
|
if (!player) {
|
|
return jsonResponse({ message: "Player not found" }, 404);
|
|
}
|
|
registerPushToken(sessionId, playerId, token, platform);
|
|
return jsonResponse({ ok: true });
|
|
},
|
|
},
|
|
};
|