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(req: Request): Promise { 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; try { body = await readJson>(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 }); }, }, };