feat: users answer alongside AI instead of proposing questions

- Core mechanic change: users now submit answers to the live prompt,
  competing alongside AI models; answers broadcast to all viewers
- New pricing (no time limit, pure count-based, refillable):
  0,99€ = 10 resp · 9,99€ = 300 resp · 19,99€ = 1000 resp
- DB: new user_answers table; submitUserAnswer() atomically validates
  credit, inserts answer, decrements budget; JUGADORES leaderboard
  now scores by user_answers count
- server: /api/respuesta/enviar endpoint; credit activation sets
  expires_at 10 years out (effectively no expiry); answers injected
  into live round state and broadcast via WebSocket
- frontend: ProposeAnswer widget shows current prompt, textarea active
  during answering phase, tracks per-round submission state;
  Arena shows Respuestas del público section live

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 12:09:53 +01:00
parent 3e5c080466
commit f9a8e2544f
5 changed files with 198 additions and 82 deletions

46
db.ts
View File

@@ -95,17 +95,30 @@ export function markQuestionUsed(id: number): void {
db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id }); db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id });
} }
/** Top 7 players by number of questions used, excluding anonymous. */ /** Top 7 players by number of answers submitted, excluding anonymous. */
export function getPlayerScores(): Record<string, number> { export function getPlayerScores(): Record<string, number> {
const rows = db const rows = db
.query( .query(
"SELECT username, COUNT(*) as score FROM questions WHERE status = 'used' AND username != '' GROUP BY username ORDER BY score DESC LIMIT 7" "SELECT username, COUNT(*) as score FROM user_answers WHERE username != '' GROUP BY username ORDER BY score DESC LIMIT 7"
) )
.all() as { username: string; score: number }[]; .all() as { username: string; score: number }[];
return Object.fromEntries(rows.map(r => [r.username, r.score])); return Object.fromEntries(rows.map(r => [r.username, r.score]));
} }
// ── Credits (question-count-based access) ─────────────────────────────────── // ── User answers (submitted during live rounds) ──────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS user_answers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
round_num INTEGER NOT NULL,
text TEXT NOT NULL,
username TEXT NOT NULL,
token TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
// ── Credits (answer-count-based access) ──────────────────────────────────────
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS credits ( CREATE TABLE IF NOT EXISTS credits (
@@ -177,38 +190,35 @@ export function getCreditByOrder(orderId: string): {
} }
/** /**
* Atomically validates a credit token, creates a paid question, and increments * Atomically validates a credit token, records a user answer for the given
* the usage counter. Returns null if the token is invalid, expired, or exhausted. * round, and decrements the answer budget. Returns null if the token is
* invalid or exhausted.
*/ */
export function consumeCreditQuestion( export function submitUserAnswer(
token: string, token: string,
roundNum: number,
text: string, text: string,
): { username: string; questionsLeft: number | null } | null { ): { username: string; answersLeft: number } | null {
const row = db const row = db
.query( .query(
"SELECT username, expires_at, max_questions, questions_used FROM credits WHERE token = $token AND status = 'active'" "SELECT username, max_questions, questions_used FROM credits WHERE token = $token AND status = 'active'"
) )
.get({ $token: token }) as { .get({ $token: token }) as {
username: string; username: string;
expires_at: number; max_questions: number;
max_questions: number | null;
questions_used: number; questions_used: number;
} | null; } | null;
if (!row) return null; if (!row) return null;
if (row.expires_at < Date.now()) return null; if (row.questions_used >= row.max_questions) return null;
if (row.max_questions !== null && row.questions_used >= row.max_questions) return null;
const orderId = crypto.randomUUID();
db.transaction(() => { db.transaction(() => {
db.prepare( db.prepare(
"INSERT INTO questions (text, order_id, username, status) VALUES ($text, $orderId, $username, 'paid')" "INSERT INTO user_answers (round_num, text, username, token) VALUES ($roundNum, $text, $username, $token)"
).run({ $text: text, $orderId: orderId, $username: row.username }); ).run({ $roundNum: roundNum, $text: text, $username: row.username, $token: token });
db.prepare( db.prepare(
"UPDATE credits SET questions_used = questions_used + 1 WHERE token = $token" "UPDATE credits SET questions_used = questions_used + 1 WHERE token = $token"
).run({ $token: token }); ).run({ $token: token });
})(); })();
const questionsLeft = return { username: row.username, answersLeft: row.max_questions - row.questions_used - 1 };
row.max_questions === null ? null : row.max_questions - row.questions_used - 1;
return { username: row.username, questionsLeft };
} }

View File

@@ -769,7 +769,53 @@ body {
white-space: nowrap; white-space: nowrap;
} }
/* ── Propose Question widget ─────────────────────────────────── */ /* ── User answers (audience) ─────────────────────────────────── */
.user-answers {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.user-answers__label {
font-family: var(--mono);
font-size: 10px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 8px;
}
.user-answers__list {
display: flex;
flex-direction: column;
gap: 6px;
}
.user-answer {
font-size: 13px;
line-height: 1.4;
}
.user-answer__name {
font-family: var(--mono);
font-size: 11px;
font-weight: 700;
color: var(--accent);
}
.user-answer__sep {
color: var(--text-muted);
}
.user-answer__text {
color: var(--text-dim);
font-family: var(--serif);
font-size: 15px;
}
/* ── Propose Answer widget ───────────────────────────────────── */
.propose { .propose {
flex-shrink: 0; flex-shrink: 0;

View File

@@ -33,6 +33,7 @@ type RoundState = {
viewerVotesA?: number; viewerVotesA?: number;
viewerVotesB?: number; viewerVotesB?: number;
viewerVotingEndsAt?: number; viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[];
}; };
type GameState = { type GameState = {
lastCompleted: RoundState | null; lastCompleted: RoundState | null;
@@ -64,15 +65,15 @@ type CreditInfo = {
username: string; username: string;
expiresAt: number; expiresAt: number;
tier: string; tier: string;
questionsLeft: number | null; answersLeft: number;
}; };
const CREDIT_STORAGE_KEY = "argumentes_credito"; const CREDIT_STORAGE_KEY = "argumentes_credito";
const PROPOSE_TIERS = [ const PROPOSE_TIERS = [
{ id: "basico", label: "10 preguntas", price: "0,99€" }, { id: "basico", label: "10 respuestas", price: "0,99€" },
{ id: "pro", label: "200 preguntas", price: "9,99€" }, { id: "pro", label: "300 respuestas", price: "9,99€" },
{ id: "ilimitado", label: "Ilimitadas", price: "19,99€" }, { id: "full", label: "1000 respuestas", price: "19,99€" },
] as const; ] as const;
type ProposeTierId = (typeof PROPOSE_TIERS)[number]["id"]; type ProposeTierId = (typeof PROPOSE_TIERS)[number]["id"];
@@ -81,12 +82,16 @@ function loadCredit(): CreditInfo | null {
try { try {
const raw = localStorage.getItem(CREDIT_STORAGE_KEY); const raw = localStorage.getItem(CREDIT_STORAGE_KEY);
if (!raw) return null; if (!raw) return null;
const c = JSON.parse(raw) as CreditInfo; const c = JSON.parse(raw) as CreditInfo & { questionsLeft?: number };
if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) { if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) {
localStorage.removeItem(CREDIT_STORAGE_KEY); localStorage.removeItem(CREDIT_STORAGE_KEY);
return null; return null;
} }
return c; // Migrate old field name
if (c.answersLeft === undefined && c.questionsLeft !== undefined) {
c.answersLeft = c.questionsLeft;
}
return c as CreditInfo;
} catch { } catch {
return null; return null;
} }
@@ -429,6 +434,21 @@ function Arena({
{isDone && votesA === votesB && totalVotes > 0 && ( {isDone && votesA === votesB && totalVotes > 0 && (
<div className="tie-label">Empate</div> <div className="tie-label">Empate</div>
)} )}
{round.userAnswers && round.userAnswers.length > 0 && (
<div className="user-answers">
<div className="user-answers__label">Respuestas del público</div>
<div className="user-answers__list">
{round.userAnswers.map((a, i) => (
<div key={i} className="user-answer">
<span className="user-answer__name">{a.username}</span>
<span className="user-answer__sep"> </span>
<span className="user-answer__text">&ldquo;{a.text}&rdquo;</span>
</div>
))}
</div>
</div>
)}
</div> </div>
); );
} }
@@ -592,9 +612,9 @@ function Standings({
); );
} }
// ── Propose Question (inline widget) ───────────────────────────────────────── // ── Propose Answer (inline widget) ───────────────────────────────────────────
function ProposeQuestion() { function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const creditOkOrder = params.get("credito_ok"); const creditOkOrder = params.get("credito_ok");
const isKo = params.get("ko") === "1"; const isKo = params.get("ko") === "1";
@@ -610,7 +630,8 @@ function ProposeQuestion() {
const [text, setText] = useState(""); const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null); const [submitError, setSubmitError] = useState<string | null>(null);
const [sent, setSent] = useState(false); const [submittedFor, setSubmittedFor] = useState<number | null>(null);
const [submittedText, setSubmittedText] = useState<string | null>(null);
const [koDismissed, setKoDismissed] = useState(false); const [koDismissed, setKoDismissed] = useState(false);
useEffect(() => { useEffect(() => {
@@ -618,6 +639,15 @@ function ProposeQuestion() {
setLoaded(true); setLoaded(true);
}, []); }, []);
// Clear submission state when a new round starts
useEffect(() => {
if (activeRound?.num && submittedFor !== null && activeRound.num !== submittedFor) {
setSubmittedFor(null);
setSubmittedText(null);
setText("");
}
}, [activeRound?.num]);
useEffect(() => { useEffect(() => {
if (!creditOkOrder || !loaded || credit) return; if (!creditOkOrder || !loaded || credit) return;
setVerifying(true); setVerifying(true);
@@ -632,13 +662,13 @@ function ProposeQuestion() {
const data = await res.json() as { const data = await res.json() as {
found: boolean; status?: string; token?: string; found: boolean; status?: string; token?: string;
username?: string; expiresAt?: number; tier?: string; username?: string; expiresAt?: number; tier?: string;
questionsLeft?: number | null; answersLeft?: number;
}; };
if (data.found && data.status === "active" && data.token && data.expiresAt) { if (data.found && data.status === "active" && data.token && data.expiresAt) {
const newCredit: CreditInfo = { const newCredit: CreditInfo = {
token: data.token, username: data.username ?? "", token: data.token, username: data.username ?? "",
expiresAt: data.expiresAt, tier: data.tier ?? "", expiresAt: data.expiresAt, tier: data.tier ?? "",
questionsLeft: data.questionsLeft ?? null, answersLeft: data.answersLeft ?? 0,
}; };
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit)); localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit));
setCredit(newCredit); setCredit(newCredit);
@@ -672,13 +702,13 @@ function ProposeQuestion() {
} }
} }
async function handleSubmitQuestion(e: React.FormEvent) { async function handleSubmitAnswer(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!credit) return; if (!credit || !activeRound) return;
setSubmitError(null); setSubmitError(null);
setSubmitting(true); setSubmitting(true);
try { try {
const res = await fetch("/api/pregunta/enviar", { const res = await fetch("/api/respuesta/enviar", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), token: credit.token }), body: JSON.stringify({ text: text.trim(), token: credit.token }),
@@ -687,17 +717,18 @@ function ProposeQuestion() {
if (res.status === 401) { if (res.status === 401) {
localStorage.removeItem(CREDIT_STORAGE_KEY); localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null); setCredit(null);
throw new Error("Acceso expirado o sin preguntas disponibles."); throw new Error("Crédito agotado o no válido.");
} }
if (res.status === 409) throw new Error("La ronda aún no tiene pregunta activa.");
throw new Error(await res.text() || `Error ${res.status}`); throw new Error(await res.text() || `Error ${res.status}`);
} }
const data = await res.json() as { ok: boolean; questionsLeft: number | null }; const data = await res.json() as { ok: boolean; answersLeft: number };
const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft }; const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated)); localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
setCredit(updated); setCredit(updated);
setSubmittedFor(activeRound.num);
setSubmittedText(text.trim());
setText(""); setText("");
setSent(true);
setTimeout(() => setSent(false), 3000);
} catch (err) { } catch (err) {
setSubmitError(err instanceof Error ? err.message : "Error al enviar"); setSubmitError(err instanceof Error ? err.message : "Error al enviar");
} finally { } finally {
@@ -708,7 +739,11 @@ function ProposeQuestion() {
if (!loaded) return null; if (!loaded) return null;
const tierInfo = selectedTier ? PROPOSE_TIERS.find(t => t.id === selectedTier) : null; const tierInfo = selectedTier ? PROPOSE_TIERS.find(t => t.id === selectedTier) : null;
const exhausted = credit !== null && credit.questionsLeft !== null && credit.questionsLeft <= 0; const exhausted = credit !== null && credit.answersLeft <= 0;
const hasPrompt = !!(activeRound?.prompt);
const alreadySubmitted = submittedFor === activeRound?.num;
const canAnswer = credit && !exhausted && hasPrompt && !alreadySubmitted &&
(activeRound?.phase === "answering" || activeRound?.phase === "prompting");
// Verifying payment // Verifying payment
if (verifying || (creditOkOrder && !credit)) { if (verifying || (creditOkOrder && !credit)) {
@@ -727,35 +762,40 @@ function ProposeQuestion() {
); );
} }
// Active credit — question form // Active credit
if (credit) { if (credit) {
const badge = credit.questionsLeft === null const badge = `${credit.answersLeft} respuesta${credit.answersLeft !== 1 ? "s" : ""}`;
? "Ilimitadas"
: `${credit.questionsLeft} restante${credit.questionsLeft !== 1 ? "s" : ""}`;
return ( return (
<div className="propose"> <div className="propose">
<div className="propose__head"> <div className="propose__head">
<span className="propose__title">Propón una pregunta · {credit.username}</span> <span className="propose__title">Responde junto a las IAs · {credit.username}</span>
<span className={`propose__badge ${exhausted ? "propose__badge--empty" : ""}`}>{badge}</span> <span className={`propose__badge ${exhausted ? "propose__badge--empty" : ""}`}>{badge}</span>
</div> </div>
{sent && <p className="propose__msg propose__msg--ok"> ¡Enviada! Se usará en el próximo sorteo.</p>}
{!exhausted ? ( {alreadySubmitted && submittedText && (
<form onSubmit={handleSubmitQuestion}> <p className="propose__msg propose__msg--ok">
Tu respuesta: &ldquo;{submittedText}&rdquo;
</p>
)}
{canAnswer && (
<form onSubmit={handleSubmitAnswer}>
<div className="propose__row"> <div className="propose__row">
<textarea <textarea
className="propose__textarea" className="propose__textarea"
value={text} value={text}
onChange={e => setText(e.target.value)} onChange={e => setText(e.target.value)}
placeholder='"La peor cosa que puedes encontrar en ___"' placeholder="Tu respuesta más graciosa…"
rows={2} rows={2}
maxLength={200} maxLength={150}
autoFocus
/> />
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 10}> <button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 3}>
{submitting ? "…" : "Enviar"} {submitting ? "…" : "Enviar"}
</button> </button>
</div> </div>
<div className="propose__hint"> <div className="propose__hint">
{text.length}/200 · mín. 10 ·{" "} {text.length}/150 ·{" "}
<button type="button" className="propose__link-btn" onClick={() => { <button type="button" className="propose__link-btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY); localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null); setCredit(null);
@@ -765,31 +805,39 @@ function ProposeQuestion() {
</div> </div>
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>} {submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
</form> </form>
) : ( )}
{!canAnswer && !alreadySubmitted && !exhausted && (
<p className="propose__msg" style={{ color: "var(--text-muted)" }}>
{!hasPrompt ? "Esperando la siguiente pregunta…" : "Ya no se aceptan respuestas para esta ronda."}
</p>
)}
{exhausted && (
<div className="propose__row"> <div className="propose__row">
<p className="propose__msg propose__msg--error" style={{ flex: 1 }}>Has agotado tus preguntas.</p> <p className="propose__msg propose__msg--error" style={{ flex: 1 }}>
Sin respuestas. Recarga para seguir jugando.
</p>
<button className="propose__btn" onClick={() => { <button className="propose__btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY); localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null); setCredit(null);
}}>Nuevo plan</button> }}>Recargar</button>
</div> </div>
)} )}
</div> </div>
); );
} }
// Tier selection // Tier selection (purchase)
return ( return (
<div className="propose"> <div className="propose">
<div className="propose__head"> <div className="propose__head">
<span className="propose__title">Propón preguntas al juego</span> <span className="propose__title">Responde junto a las IAs</span>
</div> </div>
{isKo && !koDismissed && ( {isKo && !koDismissed && (
<p className="propose__msg propose__msg--error"> <p className="propose__msg propose__msg--error">
El pago no se completó.{" "} El pago no se completó.{" "}
<button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}> <button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}>×</button>
×
</button>
</p> </p>
)} )}
<div className="propose__tiers"> <div className="propose__tiers">
@@ -1001,7 +1049,7 @@ function App() {
</div> </div>
)} )}
<ProposeQuestion /> <ProposeAnswer activeRound={state.active} />
<footer className="site-footer"> <footer className="site-footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, también puedes.</p> <p>IAs compiten respondiendo preguntas absurdas los jueces votan, también puedes.</p>

View File

@@ -67,6 +67,7 @@ export type RoundState = {
viewerVotesA?: number; viewerVotesA?: number;
viewerVotesB?: number; viewerVotesB?: number;
viewerVotingEndsAt?: number; viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[];
}; };
export type GameState = { export type GameState = {

View File

@@ -7,8 +7,7 @@ import broadcastHtml from "./broadcast.html";
import preguntaHtml from "./pregunta.html"; import preguntaHtml from "./pregunta.html";
import { import {
clearAllRounds, getRounds, getAllRounds, clearAllRounds, getRounds, getAllRounds,
createPendingQuestion, markQuestionPaid, createPendingCredit, activateCredit, getCreditByOrder, submitUserAnswer,
createPendingCredit, activateCredit, getCreditByOrder, consumeCreditQuestion,
getPlayerScores, getPlayerScores,
} from "./db.ts"; } from "./db.ts";
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts"; import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
@@ -116,10 +115,10 @@ const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000); const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000);
const ADMIN_COOKIE = "argumentes_admin"; const ADMIN_COOKIE = "argumentes_admin";
const CREDIT_TIERS: Record<string, { days: number; amount: number; label: string; maxQuestions: number | null }> = { const CREDIT_TIERS: Record<string, { amount: number; label: string; maxAnswers: number }> = {
basico: { days: 30, amount: 99, label: "10 preguntas", maxQuestions: 10 }, basico: { amount: 99, label: "10 respuestas", maxAnswers: 10 },
pro: { days: 30, amount: 999, label: "200 preguntas", maxQuestions: 200 }, pro: { amount: 999, label: "300 respuestas", maxAnswers: 300 },
ilimitado: { days: 30, amount: 1999, label: "Preguntas ilimitadas", maxQuestions: null }, full: { amount: 1999, label: "1000 respuestas", maxAnswers: 1000 },
}; };
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
@@ -561,7 +560,8 @@ const server = Bun.serve<WsData>({
if (credit && credit.status === "pending") { if (credit && credit.status === "pending") {
const tierInfo = CREDIT_TIERS[credit.tier]; const tierInfo = CREDIT_TIERS[credit.tier];
if (tierInfo) { if (tierInfo) {
const expiresAt = Date.now() + tierInfo.days * 24 * 60 * 60 * 1000; // No time limit — set expiry 10 years out so existing checks pass
const expiresAt = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
const activated = activateCredit(orderId, expiresAt); const activated = activateCredit(orderId, expiresAt);
if (activated) { if (activated) {
log("INFO", "redsys", "Credit activated", { log("INFO", "redsys", "Credit activated", {
@@ -613,14 +613,14 @@ const server = Bun.serve<WsData>({
const tierInfo = CREDIT_TIERS[tier]; const tierInfo = CREDIT_TIERS[tier];
if (!tierInfo) { if (!tierInfo) {
return new Response("Tier inválido (basico | pro | ilimitado)", { status: 400 }); return new Response("Tier inválido (basico | pro | full)", { status: 400 });
} }
if (!username || username.length < 1 || username.length > 30) { if (!username || username.length < 1 || username.length > 30) {
return new Response("El nombre debe tener entre 1 y 30 caracteres", { status: 400 }); return new Response("El nombre debe tener entre 1 y 30 caracteres", { status: 400 });
} }
const orderId = String(Date.now()).slice(-12); const orderId = String(Date.now()).slice(-12);
createPendingCredit(username, orderId, tier, tierInfo.maxQuestions); createPendingCredit(username, orderId, tier, tierInfo.maxAnswers);
const isTest = process.env.REDSYS_TEST !== "false"; const isTest = process.env.REDSYS_TEST !== "false";
const terminal = process.env.REDSYS_TERMINAL ?? "1"; const terminal = process.env.REDSYS_TERMINAL ?? "1";
@@ -638,7 +638,7 @@ const server = Bun.serve<WsData>({
urlOk: `${baseUrl}/?credito_ok=${orderId}`, urlOk: `${baseUrl}/?credito_ok=${orderId}`,
urlKo: `${baseUrl}/?ko=1`, urlKo: `${baseUrl}/?ko=1`,
merchantUrl: `${baseUrl}/api/redsys/notificacion`, merchantUrl: `${baseUrl}/api/redsys/notificacion`,
productDescription: `Acceso argument.es — ${tierInfo.label}`, productDescription: `argument.es — ${tierInfo.label}`,
}); });
log("INFO", "credito", "Credit purchase initiated", { log("INFO", "credito", "Credit purchase initiated", {
@@ -664,8 +664,9 @@ const server = Bun.serve<WsData>({
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
}); });
} }
const questionsLeft = const answersLeft = credit.maxQuestions === null
credit.maxQuestions === null ? null : credit.maxQuestions - credit.questionsUsed; ? 0
: credit.maxQuestions - credit.questionsUsed;
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
found: true, found: true,
@@ -676,7 +677,7 @@ const server = Bun.serve<WsData>({
username: credit.username, username: credit.username,
expiresAt: credit.expiresAt, expiresAt: credit.expiresAt,
tier: credit.tier, tier: credit.tier,
questionsLeft, answersLeft,
} }
: {}), : {}),
}), }),
@@ -684,14 +685,14 @@ const server = Bun.serve<WsData>({
); );
} }
if (url.pathname === "/api/pregunta/enviar") { if (url.pathname === "/api/respuesta/enviar") {
if (req.method !== "POST") { if (req.method !== "POST") {
return new Response("Method Not Allowed", { return new Response("Method Not Allowed", {
status: 405, status: 405,
headers: { Allow: "POST" }, headers: { Allow: "POST" },
}); });
} }
if (isRateLimited(`pregunta:${ip}`, 20, WINDOW_MS)) { if (isRateLimited(`respuesta:${ip}`, 20, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 }); return new Response("Too Many Requests", { status: 429 });
} }
@@ -708,16 +709,26 @@ const server = Bun.serve<WsData>({
if (!token) { if (!token) {
return new Response("Token requerido", { status: 401 }); return new Response("Token requerido", { status: 401 });
} }
if (text.length < 10 || text.length > 200) { if (text.length < 3 || text.length > 150) {
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 }); return new Response("La respuesta debe tener entre 3 y 150 caracteres", { status: 400 });
} }
const result = consumeCreditQuestion(token, text); const round = gameState.active;
if (!result) { if (!round || !round.prompt) {
return new Response("Crédito no válido, expirado o sin preguntas disponibles", { status: 401 }); return new Response("No hay ronda activa", { status: 409 });
} }
log("INFO", "pregunta", "Question submitted via credit", { username: result.username, ip });
return new Response(JSON.stringify({ ok: true, questionsLeft: result.questionsLeft }), { const result = submitUserAnswer(token, round.num, text);
if (!result) {
return new Response("Crédito no válido o sin respuestas disponibles", { status: 401 });
}
// Add to live round state and broadcast
round.userAnswers = [...(round.userAnswers ?? []), { username: result.username, text }];
broadcast();
log("INFO", "respuesta", "User answer submitted", { username: result.username, round: round.num, ip });
return new Response(JSON.stringify({ ok: true, answersLeft: result.answersLeft }), {
status: 200, status: 200,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
}); });