2026-02-20 00:28:48 -08:00
import { Database } from "bun:sqlite" ;
import type { RoundState } from "./game.ts" ;
2026-02-27 13:09:00 +01:00
const dbPath = process . env . DATABASE_PATH ? ? "argumentes.sqlite" ;
2026-02-20 03:53:37 -08:00
export const db = new Database ( dbPath , { create : true } ) ;
2026-02-20 00:28:48 -08:00
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 } ;
2026-02-20 04:49:40 -08:00
const rows = db . query ( "SELECT data FROM rounds ORDER BY num DESC, id DESC LIMIT $limit OFFSET $offset" )
2026-02-20 00:28:48 -08:00
. 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 )
} ;
}
2026-02-20 04:49:40 -08:00
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 ) ;
}
2026-02-22 01:20:19 -08:00
export function clearAllRounds() {
db . exec ( "DELETE FROM rounds;" ) ;
db . exec ( "DELETE FROM sqlite_sequence WHERE name = 'rounds';" ) ;
}
2026-02-27 14:03:30 +01:00
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
// ── Questions (user-submitted) ───────────────────────────────────────────────
2026-02-27 14:03:30 +01:00
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' ,
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
username TEXT NOT NULL DEFAULT '' ,
2026-02-27 14:03:30 +01:00
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ;
` );
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
// 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 } ) ;
2026-02-27 14:03:30 +01:00
return result . lastInsertRowid as number ;
}
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
/** 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 } ) ;
}
2026-02-27 14:03:30 +01:00
export function markQuestionPaid ( orderId : string ) : boolean {
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
const result = db
. prepare ( "UPDATE questions SET status = 'paid' WHERE order_id = $orderId AND status = 'pending'" )
. run ( { $orderId : orderId } ) ;
2026-02-27 14:03:30 +01:00
return result . changes > 0 ;
}
export function getNextPendingQuestion ( ) : { id : number ; text : string ; order_id : string } | null {
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
return db
. query ( "SELECT id, text, order_id FROM questions WHERE status = 'paid' ORDER BY id ASC LIMIT 1" )
2026-02-27 14:03:30 +01:00
. 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 } ) ;
}
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
/** 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 ] ) ) ;
}
2026-02-27 17:13:02 +01:00
// ── Credits (question-count-based access) ───────────────────────────────────
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
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
) ;
` );
2026-02-27 17:13:02 +01:00
// 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 {
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
const token = crypto . randomUUID ( ) ;
db . prepare (
2026-02-27 17:13:02 +01:00
"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 } ) ;
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
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 ;
2026-02-27 17:13:02 +01:00
maxQuestions : number | null ;
questionsUsed : number ;
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
} | null {
return db
2026-02-27 17:13:02 +01:00
. query (
"SELECT status, token, username, tier, expires_at as expiresAt, max_questions as maxQuestions, questions_used as questionsUsed FROM credits WHERE order_id = $orderId"
)
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
. get ( { $orderId : orderId } ) as {
status : string ;
token : string ;
username : string ;
tier : string ;
expiresAt : number | null ;
2026-02-27 17:13:02 +01:00
maxQuestions : number | null ;
questionsUsed : number ;
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
} | null ;
}
2026-02-27 17:13:02 +01:00
/ * *
* 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 {
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
const row = db
. query (
2026-02-27 17:13:02 +01:00
"SELECT username, expires_at, max_questions, questions_used FROM credits WHERE token = $token AND status = 'active'"
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
)
2026-02-27 17:13:02 +01:00
. get ( { $token : token } ) as {
username : string ;
expires_at : number ;
max_questions : number | null ;
questions_used : number ;
} | null ;
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
if ( ! row ) return null ;
if ( row . expires_at < Date . now ( ) ) return null ;
2026-02-27 17:13:02 +01:00
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 } ;
feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
- credits table with token, tier, expires_at, status lifecycle
- /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
- Polling-based token delivery to browser after Redsys URLOK redirect
- localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
- /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00
}