leaderboard and ranked systems
This commit is contained in:
+559
@@ -0,0 +1,559 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const passport = require('./auth/google');
|
||||
const authMiddleware = require('./middleware/auth');
|
||||
const db = require('./db');
|
||||
const { getUserRank, upsertLeaderboardEntry } = require('./services/leaderboard');
|
||||
const { calculateElo, DEFAULT_ELO } = require('./services/elo');
|
||||
const { generateStage } = require('./services/questions');
|
||||
|
||||
const VALID_GRADES = new Set(['G3', 'G6', 'G9']);
|
||||
const USERNAME_RE = /^[a-zA-Z0-9_]{3,20}$/;
|
||||
const STAGE_SIZE = 11;
|
||||
const MIN_GAME_MS = 8000; // fastest a human can answer 11 questions
|
||||
const DC_TIMEOUT_MS = 8000; // no heartbeat for this long → disconnected
|
||||
const DC_GRACE_MS = 15000; // don't check until this long after game start
|
||||
|
||||
// ── In-memory matchmaking ──────────────────────────────────────
|
||||
const queue = {}; // { userId: { grade, joinedAt, displayName, avatarUrl } }
|
||||
const playerGames = {}; // { userId: gameId }
|
||||
const activeGames = {}; // { gameId: { ... } }
|
||||
|
||||
function generateGameId() {
|
||||
return crypto.randomBytes(8).toString('hex');
|
||||
}
|
||||
|
||||
function computeGameResult(game) {
|
||||
const players = Object.keys(game.players);
|
||||
const p0 = game.players[players[0]];
|
||||
const p1 = game.players[players[1]];
|
||||
|
||||
if (p0.finished && p1.finished) {
|
||||
return p0.timeSpentMs < p1.timeSpentMs ? players[0] : players[1];
|
||||
}
|
||||
if (p0.finished) return players[0];
|
||||
if (p1.finished) return players[1];
|
||||
return null;
|
||||
}
|
||||
|
||||
async function completeGame(game, forcedWinnerId = null) {
|
||||
if (game.completing || game.status !== 'active') return;
|
||||
game.completing = true;
|
||||
|
||||
const players = Object.keys(game.players);
|
||||
const winnerId = forcedWinnerId ?? computeGameResult(game);
|
||||
game.winner = winnerId;
|
||||
game.completedAt = Date.now();
|
||||
|
||||
for (const userId of players) {
|
||||
const p = game.players[userId];
|
||||
const won = userId === winnerId;
|
||||
|
||||
const currentEntry = await db.leaderboardEntry.findUnique({
|
||||
where: { userId_grade: { userId, grade: game.grade } },
|
||||
});
|
||||
const currentElo = currentEntry?.elo ?? DEFAULT_ELO;
|
||||
const otherId = players.find(id => id !== userId);
|
||||
const otherEntry = await db.leaderboardEntry.findUnique({
|
||||
where: { userId_grade: { userId: otherId, grade: game.grade } },
|
||||
});
|
||||
const opponentElo = otherEntry?.elo ?? DEFAULT_ELO;
|
||||
const result = calculateElo(currentElo, opponentElo, won);
|
||||
|
||||
await upsertLeaderboardEntry(userId, game.grade, {
|
||||
elo: result.newElo,
|
||||
bestTime: p.timeSpentMs ?? undefined,
|
||||
correctAnswers: p.correctAnswers,
|
||||
});
|
||||
|
||||
p.eloChange = result.change;
|
||||
p.newElo = result.newElo;
|
||||
|
||||
delete playerGames[userId];
|
||||
}
|
||||
|
||||
// Set completed only after ELO is written so polls see final eloChange
|
||||
game.status = 'completed';
|
||||
|
||||
setTimeout(() => {
|
||||
delete activeGames[game.id];
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
// Disconnect detection — runs every 3s
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const game of Object.values(activeGames)) {
|
||||
if (game.status !== 'active' || game.completing) continue;
|
||||
if (now - game.startedAt < DC_GRACE_MS) continue;
|
||||
|
||||
const players = Object.keys(game.players);
|
||||
const gone = players.filter(id => {
|
||||
const p = game.players[id];
|
||||
return !p.finished && now - (p.lastSeen ?? game.startedAt) > DC_TIMEOUT_MS;
|
||||
});
|
||||
|
||||
if (gone.length === 2) {
|
||||
// Both gone — cancel, no ELO change
|
||||
game.completing = true;
|
||||
game.status = 'completed';
|
||||
game.winner = null;
|
||||
game.completedAt = now;
|
||||
for (const id of players) delete playerGames[id];
|
||||
setTimeout(() => { delete activeGames[game.id]; }, 30000);
|
||||
} else if (gone.length === 1) {
|
||||
const winnerId = players.find(id => id !== gone[0]);
|
||||
completeGame(game, winnerId);
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Cleanup stale queue entries (5min timeout) and hard-expired games every 30s
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [userId, q] of Object.entries(queue)) {
|
||||
if (now - q.joinedAt > 300000) delete queue[userId];
|
||||
}
|
||||
for (const [id, game] of Object.entries(activeGames)) {
|
||||
if (game.status === 'active' && now - game.startedAt > 300000) {
|
||||
const players = Object.keys(game.players);
|
||||
const p0 = game.players[players[0]];
|
||||
const p1 = game.players[players[1]];
|
||||
if (p0.finished || p1.finished) {
|
||||
completeGame(game);
|
||||
} else {
|
||||
game.completing = true;
|
||||
game.status = 'completed';
|
||||
game.winner = null;
|
||||
game.completedAt = now;
|
||||
for (const userId of players) delete playerGames[userId];
|
||||
delete activeGames[id];
|
||||
}
|
||||
}
|
||||
if (game.status === 'completed' && game.completedAt && now - game.completedAt > 60000) {
|
||||
delete activeGames[id];
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
app.use(cors({ origin: process.env.FRONTEND_URL, credentials: true }));
|
||||
app.use(passport.initialize());
|
||||
|
||||
app.get('/auth/google', passport.authenticate('google', {
|
||||
scope: ['profile', 'email'],
|
||||
session: false,
|
||||
}));
|
||||
|
||||
app.get('/auth/google/callback', (req, res, next) => {
|
||||
passport.authenticate('google', { session: false }, (err, result) => {
|
||||
if (err || !result) {
|
||||
console.error('[auth/google/callback] failure:', err);
|
||||
return res.redirect(`${process.env.FRONTEND_URL}/?error=auth_failed`);
|
||||
}
|
||||
res.redirect(`${process.env.FRONTEND_URL}/dashboard?token=${result.token}`);
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
app.get('/auth/me', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const user = await db.user.findUnique({ where: { id: req.user.userId } });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
res.json({
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/leaderboard', async (req, res) => {
|
||||
const { grade } = req.query;
|
||||
if (!VALID_GRADES.has(grade)) {
|
||||
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
|
||||
}
|
||||
try {
|
||||
const entries = await db.leaderboardEntry.findMany({
|
||||
where: { grade, bestTime: { not: null } },
|
||||
orderBy: { bestTime: 'asc' },
|
||||
take: 10,
|
||||
include: {
|
||||
user: { select: { displayName: true, username: true, avatarUrl: true } },
|
||||
},
|
||||
});
|
||||
res.json(entries.map((e, i) => ({
|
||||
rank: i + 1,
|
||||
displayName: e.user.displayName,
|
||||
username: e.user.username,
|
||||
avatarUrl: e.user.avatarUrl,
|
||||
bestTime: e.bestTime,
|
||||
})));
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/me/stats', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const entries = await Promise.all(
|
||||
['G3', 'G6', 'G9'].map(async grade => {
|
||||
const [eloResult, timeResult] = await Promise.all([
|
||||
getUserRank(req.user.userId, grade, 'elo'),
|
||||
getUserRank(req.user.userId, grade, 'bestTime'),
|
||||
]);
|
||||
const entry = eloResult?.entry ?? null;
|
||||
return {
|
||||
grade,
|
||||
elo: entry?.elo ?? 800,
|
||||
bestTime: entry?.bestTime ?? null,
|
||||
gamesPlayed: entry?.gamesPlayed ?? 0,
|
||||
totalCorrect: entry?.totalCorrect ?? 0,
|
||||
eloRank: eloResult?.rank ?? null,
|
||||
timeRank: timeResult?.rank ?? null,
|
||||
};
|
||||
})
|
||||
);
|
||||
res.json({ entries });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/game/complete', authMiddleware, async (req, res) => {
|
||||
const { grade, correctAnswers, totalQuestions, timeSpentMs, isRanked, won } = req.body;
|
||||
|
||||
if (!VALID_GRADES.has(grade)) {
|
||||
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
|
||||
}
|
||||
if (typeof correctAnswers !== 'number' || typeof totalQuestions !== 'number') {
|
||||
return res.status(400).json({ error: 'correctAnswers and totalQuestions required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const currentEntry = await db.leaderboardEntry.findUnique({
|
||||
where: { userId_grade: { userId, grade } },
|
||||
});
|
||||
|
||||
await upsertLeaderboardEntry(userId, grade, {
|
||||
bestTime: typeof timeSpentMs === 'number' ? Math.round(timeSpentMs / 1000) : undefined,
|
||||
correctAnswers,
|
||||
});
|
||||
|
||||
res.json({
|
||||
elo: currentEntry?.elo ?? DEFAULT_ELO,
|
||||
eloChange: 0,
|
||||
bestTime: currentEntry?.bestTime ?? null,
|
||||
gamesPlayed: (currentEntry?.gamesPlayed ?? 0) + 1,
|
||||
totalCorrect: (currentEntry?.totalCorrect ?? 0) + correctAnswers,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[game/complete] error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/me/username', authMiddleware, async (req, res) => {
|
||||
const { username } = req.body;
|
||||
if (!username || !USERNAME_RE.test(username)) {
|
||||
return res.status(400).json({ error: 'invalid' });
|
||||
}
|
||||
const lower = username.toLowerCase();
|
||||
try {
|
||||
const conflict = await db.user.findFirst({
|
||||
where: { username: lower, NOT: { id: req.user.userId } },
|
||||
});
|
||||
if (conflict) return res.status(409).json({ error: 'taken' });
|
||||
const updated = await db.user.update({
|
||||
where: { id: req.user.userId },
|
||||
data: { username: lower },
|
||||
});
|
||||
res.json({ username: updated.username });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/questions', authMiddleware, (req, res) => {
|
||||
const { grade } = req.query;
|
||||
if (!VALID_GRADES.has(grade)) {
|
||||
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
|
||||
}
|
||||
res.json(generateStage(grade, 1));
|
||||
});
|
||||
|
||||
app.post('/session/start', authMiddleware, async (req, res) => {
|
||||
const { grade, subject } = req.body;
|
||||
const VALID_SUBJECTS = new Set(['MATH', 'READING', 'WRITING']);
|
||||
if (!VALID_GRADES.has(grade) || !VALID_SUBJECTS.has(subject)) {
|
||||
return res.status(400).json({ error: 'invalid grade or subject' });
|
||||
}
|
||||
try {
|
||||
const session = await db.gameSession.create({
|
||||
data: { userId: req.user.userId, grade, subject, totalQuestions: 22, status: 'IN_PROGRESS' },
|
||||
});
|
||||
res.json({ sessionId: session.id });
|
||||
} catch (err) {
|
||||
console.error('[session/start]', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/session/complete', authMiddleware, async (req, res) => {
|
||||
const { sessionId, correctAnswers, totalQuestions } = req.body;
|
||||
if (!sessionId) return res.status(400).json({ error: 'sessionId required' });
|
||||
try {
|
||||
const session = await db.gameSession.findUnique({ where: { id: sessionId } });
|
||||
if (!session || session.userId !== req.user.userId) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
const total = totalQuestions ?? session.totalQuestions;
|
||||
await db.gameSession.update({
|
||||
where: { id: sessionId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
correctAnswers: correctAnswers ?? 0,
|
||||
totalQuestions: total,
|
||||
score: Math.round(((correctAnswers ?? 0) / (total || 1)) * 100),
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[session/complete]', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/me/best-time', authMiddleware, async (req, res) => {
|
||||
const { grade, timeMs } = req.body;
|
||||
if (!VALID_GRADES.has(grade) || typeof timeMs !== 'number' || timeMs <= 0) {
|
||||
return res.status(400).json({ error: 'Invalid grade or timeMs' });
|
||||
}
|
||||
try {
|
||||
const entry = await db.leaderboardEntry.findUnique({
|
||||
where: { userId_grade: { userId: req.user.userId, grade } },
|
||||
});
|
||||
const isNewBest = !entry || entry.bestTime === null || timeMs < entry.bestTime;
|
||||
const updated = await db.leaderboardEntry.upsert({
|
||||
where: { userId_grade: { userId: req.user.userId, grade } },
|
||||
create: { userId: req.user.userId, grade, elo: 800, bestTime: timeMs, gamesPlayed: 1 },
|
||||
update: {
|
||||
...(isNewBest ? { bestTime: timeMs } : {}),
|
||||
gamesPlayed: { increment: 1 },
|
||||
},
|
||||
});
|
||||
res.json({ updated: isNewBest, bestTime: updated.bestTime });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Matchmaking ────────────────────────────────────────────────
|
||||
|
||||
app.post('/queue/join', authMiddleware, async (req, res) => {
|
||||
const { grade } = req.body;
|
||||
if (!VALID_GRADES.has(grade)) {
|
||||
return res.status(400).json({ error: 'grade must be one of G3, G6, G9' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (playerGames[req.user.userId]) {
|
||||
return res.json({ status: 'matched', gameId: playerGames[req.user.userId] });
|
||||
}
|
||||
|
||||
if (queue[req.user.userId]) {
|
||||
return res.json({ status: 'waiting' });
|
||||
}
|
||||
|
||||
const user = await db.user.findUnique({ where: { id: req.user.userId } });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
console.log(`[queue/join] user=${req.user.userId} grade=${grade} queue_size=${Object.keys(queue).length} queue_keys=${JSON.stringify(Object.keys(queue))}`);
|
||||
const existing = Object.entries(queue).find(([id, q]) => id !== req.user.userId && q.grade === grade);
|
||||
|
||||
if (existing) {
|
||||
const [opponentId, opponentQueue] = existing;
|
||||
delete queue[opponentId];
|
||||
const gameGrade = opponentQueue.grade;
|
||||
console.log(`[queue] matched ${req.user.userId} vs ${opponentId} for ${gameGrade}`);
|
||||
|
||||
const questions = generateStage(gameGrade, 1);
|
||||
const gameId = generateGameId();
|
||||
const game = {
|
||||
id: gameId,
|
||||
grade: gameGrade,
|
||||
questions,
|
||||
players: {
|
||||
[opponentId]: { displayName: opponentQueue.displayName, username: opponentQueue.username, avatarUrl: opponentQueue.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() },
|
||||
[req.user.userId]: { displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl, currentQuestion: 0, correctAnswers: 0, finished: false, finishedAt: null, timeSpentMs: null, eloChange: 0, newElo: 800, lastSeen: Date.now() },
|
||||
},
|
||||
status: 'active',
|
||||
winner: null,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
};
|
||||
activeGames[gameId] = game;
|
||||
playerGames[opponentId] = gameId;
|
||||
playerGames[req.user.userId] = gameId;
|
||||
|
||||
return res.json({ status: 'matched', gameId });
|
||||
}
|
||||
|
||||
queue[req.user.userId] = { grade, joinedAt: Date.now(), displayName: user.displayName, username: user.username, avatarUrl: user.avatarUrl };
|
||||
res.json({ status: 'waiting' });
|
||||
} catch (err) {
|
||||
console.error('[queue/join] error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/queue/leave', authMiddleware, (req, res) => {
|
||||
delete queue[req.user.userId];
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/queue/debug', authMiddleware, (_req, res) => {
|
||||
res.json({
|
||||
queueSize: Object.keys(queue).length,
|
||||
queue: Object.entries(queue).map(([id, q]) => ({ id: id.slice(0,8), grade: q.grade, joinedAgo: Date.now() - q.joinedAt })),
|
||||
activeGames: Object.keys(activeGames).length,
|
||||
playerGames: Object.keys(playerGames).length,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/queue/status', authMiddleware, async (req, res) => {
|
||||
const gameId = playerGames[req.user.userId];
|
||||
|
||||
if (gameId) {
|
||||
const game = activeGames[gameId];
|
||||
if (!game) {
|
||||
delete playerGames[req.user.userId];
|
||||
return res.json({ status: 'not_in_queue' });
|
||||
}
|
||||
|
||||
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
|
||||
const opponent = game.players[opponentId];
|
||||
|
||||
if (!opponent) {
|
||||
delete playerGames[req.user.userId];
|
||||
delete activeGames[gameId];
|
||||
return res.json({ status: 'not_in_queue' });
|
||||
}
|
||||
|
||||
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true, avatarUrl: true } });
|
||||
|
||||
return res.json({
|
||||
status: 'matched',
|
||||
gameId,
|
||||
questions: game.questions,
|
||||
opponent: {
|
||||
username: opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName,
|
||||
avatarUrl: opponentUser?.avatarUrl ?? opponent.avatarUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (queue[req.user.userId]) {
|
||||
return res.json({ status: 'waiting' });
|
||||
}
|
||||
|
||||
res.json({ status: 'not_in_queue' });
|
||||
});
|
||||
|
||||
// ── Game management ────────────────────────────────────────────
|
||||
|
||||
app.post('/game/progress', authMiddleware, (req, res) => {
|
||||
const { gameId, currentQuestion, correctAnswers, finished, timeSpentMs } = req.body;
|
||||
const game = activeGames[gameId];
|
||||
|
||||
if (!game || game.status !== 'active') {
|
||||
return res.status(404).json({ error: 'Game not found or already completed' });
|
||||
}
|
||||
|
||||
const player = game.players[req.user.userId];
|
||||
if (!player) {
|
||||
return res.status(403).json({ error: 'Not a participant in this game' });
|
||||
}
|
||||
|
||||
if (player.finished) return res.json({ success: true }); // idempotent
|
||||
|
||||
player.lastSeen = Date.now();
|
||||
|
||||
if (currentQuestion !== undefined) {
|
||||
player.currentQuestion = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(currentQuestion))));
|
||||
}
|
||||
if (correctAnswers !== undefined) {
|
||||
player.correctAnswers = Math.max(0, Math.min(STAGE_SIZE, Math.floor(Number(correctAnswers))));
|
||||
}
|
||||
|
||||
if (finished) {
|
||||
const elapsed = Date.now() - game.startedAt;
|
||||
const claimed = typeof timeSpentMs === 'number' ? timeSpentMs : elapsed;
|
||||
// Clamp: no faster than minimum human time, no slower than actual elapsed + small buffer
|
||||
player.timeSpentMs = Math.max(MIN_GAME_MS, Math.min(claimed, elapsed + 5000));
|
||||
player.finished = true;
|
||||
player.finishedAt = Date.now();
|
||||
|
||||
const winner = computeGameResult(game);
|
||||
if (winner) completeGame(game);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/game/status/:gameId', authMiddleware, async (req, res) => {
|
||||
const game = activeGames[req.params.gameId];
|
||||
if (!game) {
|
||||
return res.status(404).json({ error: 'Game not found' });
|
||||
}
|
||||
|
||||
const player = game.players[req.user.userId];
|
||||
if (!player) {
|
||||
return res.status(403).json({ error: 'Not a participant in this game' });
|
||||
}
|
||||
|
||||
player.lastSeen = Date.now();
|
||||
|
||||
const opponentId = Object.keys(game.players).find(id => id !== req.user.userId);
|
||||
const opponent = game.players[opponentId];
|
||||
|
||||
const opponentUser = await db.user.findUnique({ where: { id: opponentId }, select: { username: true, displayName: true } });
|
||||
const opponentName = opponentUser?.username ?? opponentUser?.displayName ?? opponent.displayName;
|
||||
|
||||
res.json({
|
||||
status: game.status,
|
||||
youWon: game.winner !== null && game.winner === req.user.userId,
|
||||
players: {
|
||||
you: {
|
||||
currentQuestion: player.currentQuestion,
|
||||
correctAnswers: player.correctAnswers,
|
||||
finished: player.finished,
|
||||
timeSpentMs: player.timeSpentMs,
|
||||
eloChange: player.eloChange,
|
||||
newElo: player.newElo,
|
||||
},
|
||||
opponent: {
|
||||
username: opponentName,
|
||||
currentQuestion: opponent.currentQuestion,
|
||||
correctAnswers: opponent.correctAnswers,
|
||||
finished: opponent.finished,
|
||||
timeSpentMs: opponent.timeSpentMs,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
|
||||
Reference in New Issue
Block a user