- Pricing: 0,99€/10q · 9,99€/200q · 19,99€/unlimited (all 30 days) - DB: max_questions + questions_used columns on credits table; consumeCreditQuestion() atomically validates, creates question, and decrements quota in a single transaction - Server: updated CREDIT_TIERS, /api/credito/estado returns questionsLeft, /api/pregunta/enviar returns updated questionsLeft after each submission - pregunta.tsx: badge shows live question count; submit disabled when exhausted; questionsLeft synced to localStorage after each submission; Cloud Host footer added - footer: moved from Standings sidebar into <main> (scrolls with game content); also added to all pregunta page states Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
7.6 KiB
TypeScript
215 lines
7.6 KiB
TypeScript
import { Database } from "bun:sqlite";
|
|
import type { RoundState } from "./game.ts";
|
|
|
|
const dbPath = process.env.DATABASE_PATH ?? "argumentes.sqlite";
|
|
export const db = new Database(dbPath, { create: true });
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS rounds (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
num INTEGER,
|
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
data TEXT
|
|
);
|
|
`);
|
|
|
|
export function saveRound(round: RoundState) {
|
|
const insert = db.prepare("INSERT INTO rounds (num, data) VALUES ($num, $data)");
|
|
insert.run({ $num: round.num, $data: JSON.stringify(round) });
|
|
}
|
|
|
|
export function getRounds(page: number = 1, limit: number = 10) {
|
|
const offset = (page - 1) * limit;
|
|
const countQuery = db.query("SELECT COUNT(*) as count FROM rounds").get() as { count: number };
|
|
const rows = db.query("SELECT data FROM rounds ORDER BY num DESC, id DESC LIMIT $limit OFFSET $offset")
|
|
.all({ $limit: limit, $offset: offset }) as { data: string }[];
|
|
return {
|
|
rounds: rows.map(r => JSON.parse(r.data) as RoundState),
|
|
total: countQuery.count,
|
|
page,
|
|
limit,
|
|
totalPages: Math.ceil(countQuery.count / limit)
|
|
};
|
|
}
|
|
|
|
export function getAllRounds() {
|
|
const rows = db.query("SELECT data FROM rounds ORDER BY num ASC, id ASC").all() as { data: string }[];
|
|
return rows.map(r => JSON.parse(r.data) as RoundState);
|
|
}
|
|
|
|
export function clearAllRounds() {
|
|
db.exec("DELETE FROM rounds;");
|
|
db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';");
|
|
}
|
|
|
|
// ── Questions (user-submitted) ───────────────────────────────────────────────
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS questions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
text TEXT NOT NULL,
|
|
order_id TEXT NOT NULL UNIQUE,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
username TEXT NOT NULL DEFAULT '',
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
`);
|
|
|
|
// Migration: add username column to pre-existing questions tables
|
|
try {
|
|
db.exec("ALTER TABLE questions ADD COLUMN username TEXT NOT NULL DEFAULT ''");
|
|
} catch {
|
|
// Column already exists — no-op
|
|
}
|
|
|
|
export function createPendingQuestion(text: string, orderId: string, username = ""): number {
|
|
const stmt = db.prepare(
|
|
"INSERT INTO questions (text, order_id, username) VALUES ($text, $orderId, $username)"
|
|
);
|
|
const result = stmt.run({ $text: text, $orderId: orderId, $username: username });
|
|
return result.lastInsertRowid as number;
|
|
}
|
|
|
|
/** Creates a question that is immediately ready (used for credit-based submissions). */
|
|
export function createPaidQuestion(text: string, username: string): void {
|
|
const orderId = crypto.randomUUID();
|
|
db.prepare(
|
|
"INSERT INTO questions (text, order_id, username, status) VALUES ($text, $orderId, $username, 'paid')"
|
|
).run({ $text: text, $orderId: orderId, $username: username });
|
|
}
|
|
|
|
export function markQuestionPaid(orderId: string): boolean {
|
|
const result = db
|
|
.prepare("UPDATE questions SET status = 'paid' WHERE order_id = $orderId AND status = 'pending'")
|
|
.run({ $orderId: orderId });
|
|
return result.changes > 0;
|
|
}
|
|
|
|
export function getNextPendingQuestion(): { id: number; text: string; order_id: string } | null {
|
|
return db
|
|
.query("SELECT id, text, order_id FROM questions WHERE status = 'paid' ORDER BY id ASC LIMIT 1")
|
|
.get() as { id: number; text: string; order_id: string } | null;
|
|
}
|
|
|
|
export function markQuestionUsed(id: number): void {
|
|
db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id });
|
|
}
|
|
|
|
/** Top 7 players by number of questions used, excluding anonymous. */
|
|
export function getPlayerScores(): Record<string, number> {
|
|
const rows = db
|
|
.query(
|
|
"SELECT username, COUNT(*) as score FROM questions WHERE status = 'used' AND username != '' GROUP BY username ORDER BY score DESC LIMIT 7"
|
|
)
|
|
.all() as { username: string; score: number }[];
|
|
return Object.fromEntries(rows.map(r => [r.username, r.score]));
|
|
}
|
|
|
|
// ── Credits (question-count-based access) ───────────────────────────────────
|
|
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS credits (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT NOT NULL,
|
|
token TEXT NOT NULL UNIQUE,
|
|
tier TEXT NOT NULL,
|
|
order_id TEXT NOT NULL UNIQUE,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
expires_at INTEGER,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
`);
|
|
|
|
// Migrations for question-tracking columns
|
|
try {
|
|
db.exec("ALTER TABLE credits ADD COLUMN max_questions INTEGER");
|
|
} catch {
|
|
// Column already exists — no-op
|
|
}
|
|
try {
|
|
db.exec("ALTER TABLE credits ADD COLUMN questions_used INTEGER NOT NULL DEFAULT 0");
|
|
} catch {
|
|
// Column already exists — no-op
|
|
}
|
|
|
|
export function createPendingCredit(username: string, orderId: string, tier: string, maxQuestions: number | null): string {
|
|
const token = crypto.randomUUID();
|
|
db.prepare(
|
|
"INSERT INTO credits (username, token, tier, order_id, max_questions) VALUES ($username, $token, $tier, $orderId, $maxQuestions)"
|
|
).run({ $username: username, $token: token, $tier: tier, $orderId: orderId, $maxQuestions: maxQuestions });
|
|
return token;
|
|
}
|
|
|
|
export function activateCredit(
|
|
orderId: string,
|
|
expiresAt: number,
|
|
): { token: string; username: string } | null {
|
|
db.prepare(
|
|
"UPDATE credits SET status = 'active', expires_at = $expiresAt WHERE order_id = $orderId AND status = 'pending'"
|
|
).run({ $expiresAt: expiresAt, $orderId: orderId });
|
|
return db
|
|
.query("SELECT token, username FROM credits WHERE order_id = $orderId AND status = 'active'")
|
|
.get({ $orderId: orderId }) as { token: string; username: string } | null;
|
|
}
|
|
|
|
export function getCreditByOrder(orderId: string): {
|
|
status: string;
|
|
token: string;
|
|
username: string;
|
|
tier: string;
|
|
expiresAt: number | null;
|
|
maxQuestions: number | null;
|
|
questionsUsed: number;
|
|
} | null {
|
|
return db
|
|
.query(
|
|
"SELECT status, token, username, tier, expires_at as expiresAt, max_questions as maxQuestions, questions_used as questionsUsed FROM credits WHERE order_id = $orderId"
|
|
)
|
|
.get({ $orderId: orderId }) as {
|
|
status: string;
|
|
token: string;
|
|
username: string;
|
|
tier: string;
|
|
expiresAt: number | null;
|
|
maxQuestions: number | null;
|
|
questionsUsed: number;
|
|
} | null;
|
|
}
|
|
|
|
/**
|
|
* Atomically validates a credit token, creates a paid question, and increments
|
|
* the usage counter. Returns null if the token is invalid, expired, or exhausted.
|
|
*/
|
|
export function consumeCreditQuestion(
|
|
token: string,
|
|
text: string,
|
|
): { username: string; questionsLeft: number | null } | null {
|
|
const row = db
|
|
.query(
|
|
"SELECT username, expires_at, max_questions, questions_used FROM credits WHERE token = $token AND status = 'active'"
|
|
)
|
|
.get({ $token: token }) as {
|
|
username: string;
|
|
expires_at: number;
|
|
max_questions: number | null;
|
|
questions_used: number;
|
|
} | null;
|
|
if (!row) return null;
|
|
if (row.expires_at < Date.now()) return null;
|
|
if (row.max_questions !== null && row.questions_used >= row.max_questions) return null;
|
|
|
|
const orderId = crypto.randomUUID();
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
"INSERT INTO questions (text, order_id, username, status) VALUES ($text, $orderId, $username, 'paid')"
|
|
).run({ $text: text, $orderId: orderId, $username: row.username });
|
|
db.prepare(
|
|
"UPDATE credits SET questions_used = questions_used + 1 WHERE token = $token"
|
|
).run({ $token: token });
|
|
})();
|
|
|
|
const questionsLeft =
|
|
row.max_questions === null ? null : row.max_questions - row.questions_used - 1;
|
|
return { username: row.username, questionsLeft };
|
|
}
|