Triviaverse / src /app /api /db /route.ts
Simeon Garratt
feat: remove Supabase, bundle questions locally, use JSON DB + API
ad111f4
Raw
History Blame Contribute Delete
18.8 kB
/**
* Unified database API — replaces all Supabase queries.
*
* All operations use POST with a JSON body containing:
* { table, action, ...args }
*
* See the action handlers below for per-table operations.
*/
import { NextRequest, NextResponse } from "next/server";
import {
readDb,
writeDb,
nextId,
generateUniqueClaimCode,
getTimestamp,
type DbData,
type PlayerRecord,
type LeaderboardEntryRecord,
} from "@/lib/server/db";
// ---------------------------------------------------------------------------
// Action router
// ---------------------------------------------------------------------------
async function handle(req: NextRequest) {
const body = await req.json().catch(() => ({}));
const { table, action } = body;
const handlers = {
players: handlePlayers,
leaderboard: handleLeaderboard,
sessions: handleSessions,
achievements: handleAchievements,
cosmetics: handleCosmetics,
matches: handleMatches,
debates: handleDebates,
stats: handleStats,
admin: handleAdmin,
} as Record<string, (b: Record<string, unknown>) => unknown>;
const tableHandler = handlers[table as string];
if (!tableHandler) {
return NextResponse.json({ error: `Unknown table: ${table}` }, { status: 400 });
}
try {
const result = await tableHandler(body);
return NextResponse.json(result);
} catch (err) {
console.error(`[api/db] ${table}/${action} error:`, err);
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
export const POST = handle;
// ---------------------------------------------------------------------------
// Players
// ---------------------------------------------------------------------------
function handlePlayers(body: Record<string, unknown>): unknown {
const action = body.action as string;
const db = readDb();
switch (action) {
case "get_or_create": {
const anonymousId = body.anonymousId as string;
const displayName = body.displayName as string;
if (!anonymousId || !displayName) return null;
const existing = db.players.find((p) => p.anonymous_id === anonymousId);
if (existing) {
if (existing.display_name !== displayName) {
existing.display_name = displayName;
writeDb(db);
}
return existing;
}
const player: PlayerRecord = {
id: nextId(),
display_name: displayName,
anonymous_id: anonymousId,
elo_rating: 1200,
games_played: 0,
star_coins: 0,
claim_code: generateUniqueClaimCode(),
spectrum_position: 6,
created_at: getTimestamp(),
};
db.players.push(player);
writeDb(db);
return player;
}
case "get_by_id": {
const id = body.id as string;
return db.players.find((p) => p.id === id) ?? null;
}
case "get_by_claim_code": {
const code = (body.code as string)?.toUpperCase().trim();
return db.players.find((p) => p.claim_code === code) ?? null;
}
case "update_name": {
const id = body.id as string;
const name = body.displayName as string;
const player = db.players.find((p) => p.id === id);
if (player) {
player.display_name = name;
writeDb(db);
return player;
}
return null;
}
case "add_star_coins": {
const id = body.id as string;
const amount = Number(body.amount) || 0;
const player = db.players.find((p) => p.id === id);
if (player) {
player.star_coins += amount;
writeDb(db);
return player.star_coins;
}
return null;
}
case "get_star_coins": {
const id = body.id as string;
const player = db.players.find((p) => p.id === id);
return player?.star_coins ?? 0;
}
case "increment_games_played": {
const id = body.id as string;
const player = db.players.find((p) => p.id === id);
if (player) {
player.games_played++;
writeDb(db);
}
return null;
}
case "set_spectrum": {
const id = body.id as string;
const position = Number(body.position) || 6;
const player = db.players.find((p) => p.id === id);
if (player) {
player.spectrum_position = position;
writeDb(db);
}
return null;
}
case "get_spectrum": {
const id = body.id as string;
const player = db.players.find((p) => p.id === id);
return player?.spectrum_position ?? null;
}
default:
return { error: `Unknown players action: ${action}` };
}
}
// ---------------------------------------------------------------------------
// Leaderboard
// ---------------------------------------------------------------------------
function handleLeaderboard(body: Record<string, unknown>): unknown {
const action = body.action as string;
const db = readDb();
switch (action) {
case "get": {
const mode = body.mode as string;
const limit = Number(body.limit) || 10;
const asc = body.ascending === true;
let entries = db.leaderboard_entries.filter((e) => e.mode === mode);
entries.sort((a, b) => asc ? a.score - b.score : b.score - a.score);
return entries.slice(0, limit);
}
case "get_all_modes": {
const limit = Number(body.limit) || 5;
const modes = ["quick-fire", "challenge-me", "streak", "multiplayer"] as const;
const result: Record<string, LeaderboardEntryRecord[]> = {};
for (const mode of modes) {
let entries = db.leaderboard_entries.filter((e) => e.mode === mode);
entries.sort((a, b) => b.score - a.score);
result[mode] = entries.slice(0, limit);
}
return result;
}
case "submit": {
const entry = body.entry as Record<string, unknown>;
if (!entry) return null;
const newEntry: LeaderboardEntryRecord = {
id: nextId(),
player_id: entry.player_id as string,
player_name: entry.player_name as string,
mode: entry.mode as string,
score: Number(entry.score) || 0,
accuracy: Number(entry.accuracy) || 0,
season_id: (entry.season_id as string) ?? null,
created_at: getTimestamp(),
};
db.leaderboard_entries.push(newEntry);
writeDb(db);
return newEntry;
}
default:
return { error: `Unknown leaderboard action: ${action}` };
}
}
// ---------------------------------------------------------------------------
// Game Sessions
// ---------------------------------------------------------------------------
function handleSessions(body: Record<string, unknown>): unknown {
const action = body.action as string;
const db = readDb();
switch (action) {
case "record": {
const s = body.session as Record<string, unknown>;
if (!s) return null;
const session = {
id: nextId(),
player_id: s.player_id as string,
mode: s.mode as string,
score: Number(s.score) || 0,
accuracy: Number(s.accuracy) || 0,
streak_max: Number(s.streak_max) || 0,
duration_ms: Number(s.duration_ms) || 0,
questions_answered: Number(s.questions_answered) || 0,
questions_correct: Number(s.questions_correct) || 0,
created_at: getTimestamp(),
};
db.game_sessions.push(session);
writeDb(db);
// Increment games played
const player = db.players.find((p) => p.id === session.player_id);
if (player) player.games_played++;
writeDb(db);
return session;
}
default:
return { error: `Unknown sessions action: ${action}` };
}
}
// ---------------------------------------------------------------------------
// Player Achievements
// ---------------------------------------------------------------------------
function handleAchievements(body: Record<string, unknown>): unknown {
const action = body.action as string;
const db = readDb();
switch (action) {
case "get": {
const playerId = body.playerId as string;
return db.player_achievements.filter((a) => a.player_id === playerId);
}
case "get_player_stats": {
const playerId = body.playerId as string;
const sessions = db.game_sessions.filter((s) => s.player_id === playerId);
return {
totalGames: sessions.length,
distinctModes: [...new Set(sessions.map((s) => s.mode))],
totalCorrectAnswers: sessions.reduce((sum, s) => sum + s.questions_correct, 0),
};
}
case "upsert": {
const playerId = body.playerId as string;
const achievementId = body.achievementId as string;
const progress = Number(body.progress) || 0;
const unlocked = body.unlocked === true;
const existing = db.player_achievements.find(
(a) => a.player_id === playerId && a.achievement_id === achievementId
);
if (existing) {
existing.progress = Math.max(existing.progress, progress);
existing.unlocked = existing.unlocked || unlocked;
if (unlocked) existing.unlocked_at = existing.unlocked_at ?? getTimestamp();
} else {
db.player_achievements.push({
id: nextId(),
player_id: playerId,
achievement_id: achievementId,
progress,
unlocked,
unlocked_at: unlocked ? getTimestamp() : null,
});
}
writeDb(db);
return null;
}
default:
return { error: `Unknown achievements action: ${action}` };
}
}
// ---------------------------------------------------------------------------
// Cosmetics
// ---------------------------------------------------------------------------
function handleCosmetics(body: Record<string, unknown>): unknown {
const action = body.action as string;
const db = readDb();
switch (action) {
case "get_cosmetics": {
const playerId = body.playerId as string;
return db.player_cosmetics.filter((c) => c.player_id === playerId);
}
case "get_equipped": {
const playerId = body.playerId as string;
return db.player_equipped.filter((e) => e.player_id === playerId);
}
case "purchase": {
const playerId = body.playerId as string;
const cosmeticId = body.cosmeticId as string;
const price = Number(body.price) || 0;
const player = db.players.find((p) => p.id === playerId);
if (!player) return false;
if (player.star_coins < price) return false;
player.star_coins -= price;
db.player_cosmetics.push({
id: nextId(),
player_id: playerId,
cosmetic_id: cosmeticId,
purchased_at: getTimestamp(),
});
writeDb(db);
return true;
}
case "equip": {
const playerId = body.playerId as string;
const category = body.category as string;
const cosmeticId = body.cosmeticId as string;
const existing = db.player_equipped.find(
(e) => e.player_id === playerId && e.category === category
);
if (existing) {
existing.cosmetic_id = cosmeticId;
} else {
db.player_equipped.push({
id: nextId(),
player_id: playerId,
category,
cosmetic_id: cosmeticId,
});
}
writeDb(db);
return true;
}
case "unequip": {
const playerId = body.playerId as string;
const category = body.category as string;
db.player_equipped = db.player_equipped.filter(
(e) => !(e.player_id === playerId && e.category === category)
);
writeDb(db);
return true;
}
default:
return { error: `Unknown cosmetics action: ${action}` };
}
}
// ---------------------------------------------------------------------------
// Matches
// ---------------------------------------------------------------------------
function handleMatches(body: Record<string, unknown>): unknown {
const action = body.action as string;
const db = readDb();
switch (action) {
case "record": {
const m = body.match as Record<string, unknown>;
if (!m) return null;
const match = {
id: nextId(),
player1_id: m.player1_id as string,
player2_id: m.player2_id as string,
player1_score: Number(m.player1_score) || 0,
player2_score: Number(m.player2_score) || 0,
winner_id: (m.winner_id as string) ?? null,
elo_change: Number(m.elo_change) || 0,
question_ids: (m.question_ids as string[]) ?? [],
created_at: getTimestamp(),
};
db.matches.push(match);
writeDb(db);
return match;
}
default:
return { error: `Unknown matches action: ${action}` };
}
}
// ---------------------------------------------------------------------------
// Debates (Spark)
// ---------------------------------------------------------------------------
function handleDebates(body: Record<string, unknown>): unknown {
const action = body.action as string;
const db = readDb();
switch (action) {
case "record_debate": {
const d = body.debate as Record<string, unknown>;
if (!d) return null;
const debate = {
id: nextId(),
topic_id: (d.topic_id as string) ?? "",
player1_id: (d.player1_id as string) ?? null,
player2_id: (d.player2_id as string) ?? null,
duration_minutes: Number(d.duration_minutes) || 5,
rounds: Number(d.rounds) || 0,
winner_id: (d.winner_id as string) ?? null,
player1_score: (d.player1_score as number) ?? null,
player2_score: (d.player2_score as number) ?? null,
verdict: (d.verdict as string) ?? null,
elo_change: Number(d.elo_change) || 0,
is_practice: d.is_practice === true,
created_at: getTimestamp(),
};
db.debates.push(debate);
writeDb(db);
return debate;
}
case "get_preview": {
const [latest] = db.debates.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
if (!latest) return null;
const round = db.debate_rounds
.filter((r) => r.debate_id === latest.id)
.sort((a, b) => a.round_number - b.round_number)[0];
return {
topic: `Debate #${latest.id}`,
question: `A competitive debate on a challenging topic.`,
openerArgument: round?.player1_argument ?? "A compelling opening argument was made.",
rebuttalArgument: round?.player2_argument ?? "A strong rebuttal followed.",
commentary: null,
};
}
case "get_leaderboard": {
const limit = Number(body.limit) || 5;
return db.players
.filter((p) => p.elo_rating !== 1200 || p.games_played > 0)
.sort((a, b) => b.elo_rating - a.elo_rating)
.slice(0, limit)
.map((p) => ({
name: p.display_name,
elo: p.elo_rating,
gamesPlayed: p.games_played,
}));
}
case "get_recent": {
const limit = Number(body.limit) || 5;
return db.debates
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, limit)
.map((d) => {
const p1 = db.players.find((p) => p.id === d.player1_id);
const p2 = db.players.find((p) => p.id === d.player2_id);
return {
topic: `Debate #${d.id}`,
player1Score: d.player1_score ?? 0,
player2Score: d.player2_score ?? 0,
winnerName: d.winner_id
? db.players.find((p) => p.id === d.winner_id)?.display_name ?? "Unknown"
: "Tie",
createdAt: d.created_at,
};
});
}
case "get_total_count": {
return db.debates.length;
}
default:
return { error: `Unknown debates action: ${action}` };
}
}
// ---------------------------------------------------------------------------
// Stats / Admin
// ---------------------------------------------------------------------------
function handleStats(body: Record<string, unknown>): unknown {
const action = body.action as string;
switch (action) {
case "total_games": {
const db = readDb();
return db.game_sessions.length;
}
default:
return { error: `Unknown stats action: ${action}` };
}
}
function handleAdmin(_body: Record<string, unknown>): unknown {
const db = readDb();
const modeMap = new Map<string, { total: number; scoreSum: number; accSum: number }>();
for (const s of db.game_sessions) {
const existing = modeMap.get(s.mode) ?? { total: 0, scoreSum: 0, accSum: 0 };
modeMap.set(s.mode, {
total: existing.total + 1,
scoreSum: existing.scoreSum + s.score,
accSum: existing.accSum + s.accuracy,
});
}
const modeBreakdown = Array.from(modeMap.entries())
.map(([mode, d]) => ({
mode,
count: d.total,
avgScore: Math.round(d.scoreSum / d.total),
avgAccuracy: Math.round((d.accSum / d.total) * 100),
}))
.sort((a, b) => b.count - a.count);
const recentSessions = db.game_sessions
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 10)
.map((s) => {
const player = db.players.find((p) => p.id === s.player_id);
return {
id: s.id,
mode: s.mode,
score: s.score,
accuracy: s.accuracy,
created_at: s.created_at,
playerName: player?.display_name ?? "Unknown",
};
});
return {
totalPlayers: db.players.length,
totalSessions: db.game_sessions.length,
totalMatches: db.matches.length,
totalDebates: db.debates.length,
modeBreakdown,
recentSessions,
topPlayers: db.leaderboard_entries
.sort((a, b) => b.score - a.score)
.slice(0, 5)
.map((e) => ({ name: e.player_name, score: e.score, mode: e.mode })),
recentDebates: db.debates
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 10)
.map((d) => ({
id: d.id,
created_at: d.created_at,
winner_id: d.winner_id,
player1_id: d.player1_id,
player2_id: d.player2_id,
player1_name: db.players.find((p) => p.id === d.player1_id)?.display_name ?? "Unknown",
player2_name: db.players.find((p) => p.id === d.player2_id)?.display_name ?? "Unknown",
topic: `Debate #${d.id}`,
verdict: d.verdict,
})),
topDebaters: db.players
.filter((p) => p.spectrum_position && p.games_played > 0)
.sort((a, b) => b.elo_rating - a.elo_rating)
.slice(0, 10)
.map((p) => ({
name: p.display_name,
elo: p.elo_rating,
games: p.games_played,
spectrumPosition: p.spectrum_position,
})),
};
}