CoompanionApp/server/api.ts

389 lines
13 KiB
TypeScript
Raw Permalink Normal View History

2026-02-03 13:48:56 +01:00
import type { SessionSnapshot } from "../shared/types";
import type { BunRequest } from "bun";
2026-02-03 16:35:01 +01:00
import {
applySnapshot,
claimTakeover,
joinSession,
requestTakeover,
requestTakeoverByToken,
snapshotSession,
} from "./domain";
2026-02-03 13:48:56 +01:00
import {
createSession,
createTestPreview,
createTestSession,
getSession,
getSessionByCode,
isTestSessionCode,
} from "./store";
import { broadcastSessionState, startTestSimulation } from "./websocket";
2026-02-03 16:35:01 +01:00
import { registerPushToken } from "./notifications";
2026-02-03 13:48:56 +01:00
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);
}
2026-02-03 13:48:56 +01:00
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 });
},
},
2026-02-03 13:48:56 +01:00
"/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));
},
},
2026-02-03 16:35:01 +01:00
"/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 });
},
},
2026-02-03 13:48:56 +01:00
};