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:
46
db.ts
46
db.ts
@@ -95,17 +95,30 @@ 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. */
|
||||
/** Top 7 players by number of answers submitted, 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"
|
||||
"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 }[];
|
||||
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(`
|
||||
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
|
||||
* the usage counter. Returns null if the token is invalid, expired, or exhausted.
|
||||
* Atomically validates a credit token, records a user answer for the given
|
||||
* round, and decrements the answer budget. Returns null if the token is
|
||||
* invalid or exhausted.
|
||||
*/
|
||||
export function consumeCreditQuestion(
|
||||
export function submitUserAnswer(
|
||||
token: string,
|
||||
roundNum: number,
|
||||
text: string,
|
||||
): { username: string; questionsLeft: number | null } | null {
|
||||
): { username: string; answersLeft: number } | null {
|
||||
const row = db
|
||||
.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 {
|
||||
username: string;
|
||||
expires_at: number;
|
||||
max_questions: number | null;
|
||||
max_questions: number;
|
||||
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;
|
||||
if (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 });
|
||||
"INSERT INTO user_answers (round_num, text, username, token) VALUES ($roundNum, $text, $username, $token)"
|
||||
).run({ $roundNum: roundNum, $text: text, $username: row.username, $token: token });
|
||||
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 };
|
||||
return { username: row.username, answersLeft: row.max_questions - row.questions_used - 1 };
|
||||
}
|
||||
|
||||
48
frontend.css
48
frontend.css
@@ -769,7 +769,53 @@ body {
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
|
||||
130
frontend.tsx
130
frontend.tsx
@@ -33,6 +33,7 @@ type RoundState = {
|
||||
viewerVotesA?: number;
|
||||
viewerVotesB?: number;
|
||||
viewerVotingEndsAt?: number;
|
||||
userAnswers?: { username: string; text: string }[];
|
||||
};
|
||||
type GameState = {
|
||||
lastCompleted: RoundState | null;
|
||||
@@ -64,15 +65,15 @@ type CreditInfo = {
|
||||
username: string;
|
||||
expiresAt: number;
|
||||
tier: string;
|
||||
questionsLeft: number | null;
|
||||
answersLeft: number;
|
||||
};
|
||||
|
||||
const CREDIT_STORAGE_KEY = "argumentes_credito";
|
||||
|
||||
const PROPOSE_TIERS = [
|
||||
{ id: "basico", label: "10 preguntas", price: "0,99€" },
|
||||
{ id: "pro", label: "200 preguntas", price: "9,99€" },
|
||||
{ id: "ilimitado", label: "Ilimitadas", price: "19,99€" },
|
||||
{ id: "basico", label: "10 respuestas", price: "0,99€" },
|
||||
{ id: "pro", label: "300 respuestas", price: "9,99€" },
|
||||
{ id: "full", label: "1000 respuestas", price: "19,99€" },
|
||||
] as const;
|
||||
|
||||
type ProposeTierId = (typeof PROPOSE_TIERS)[number]["id"];
|
||||
@@ -81,12 +82,16 @@ function loadCredit(): CreditInfo | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CREDIT_STORAGE_KEY);
|
||||
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()) {
|
||||
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
return c;
|
||||
// Migrate old field name
|
||||
if (c.answersLeft === undefined && c.questionsLeft !== undefined) {
|
||||
c.answersLeft = c.questionsLeft;
|
||||
}
|
||||
return c as CreditInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -429,6 +434,21 @@ function Arena({
|
||||
{isDone && votesA === votesB && totalVotes > 0 && (
|
||||
<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">“{a.text}”</span>
|
||||
</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 creditOkOrder = params.get("credito_ok");
|
||||
const isKo = params.get("ko") === "1";
|
||||
@@ -610,7 +630,8 @@ function ProposeQuestion() {
|
||||
const [text, setText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -618,6 +639,15 @@ function ProposeQuestion() {
|
||||
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(() => {
|
||||
if (!creditOkOrder || !loaded || credit) return;
|
||||
setVerifying(true);
|
||||
@@ -632,13 +662,13 @@ function ProposeQuestion() {
|
||||
const data = await res.json() as {
|
||||
found: boolean; status?: string; token?: string;
|
||||
username?: string; expiresAt?: number; tier?: string;
|
||||
questionsLeft?: number | null;
|
||||
answersLeft?: number;
|
||||
};
|
||||
if (data.found && data.status === "active" && data.token && data.expiresAt) {
|
||||
const newCredit: CreditInfo = {
|
||||
token: data.token, username: data.username ?? "",
|
||||
expiresAt: data.expiresAt, tier: data.tier ?? "",
|
||||
questionsLeft: data.questionsLeft ?? null,
|
||||
answersLeft: data.answersLeft ?? 0,
|
||||
};
|
||||
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit));
|
||||
setCredit(newCredit);
|
||||
@@ -672,13 +702,13 @@ function ProposeQuestion() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitQuestion(e: React.FormEvent) {
|
||||
async function handleSubmitAnswer(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!credit) return;
|
||||
if (!credit || !activeRound) return;
|
||||
setSubmitError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await fetch("/api/pregunta/enviar", {
|
||||
const res = await fetch("/api/respuesta/enviar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: text.trim(), token: credit.token }),
|
||||
@@ -687,17 +717,18 @@ function ProposeQuestion() {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||
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}`);
|
||||
}
|
||||
const data = await res.json() as { ok: boolean; questionsLeft: number | null };
|
||||
const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft };
|
||||
const data = await res.json() as { ok: boolean; answersLeft: number };
|
||||
const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
|
||||
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
|
||||
setCredit(updated);
|
||||
setSubmittedFor(activeRound.num);
|
||||
setSubmittedText(text.trim());
|
||||
setText("");
|
||||
setSent(true);
|
||||
setTimeout(() => setSent(false), 3000);
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
|
||||
} finally {
|
||||
@@ -708,7 +739,11 @@ function ProposeQuestion() {
|
||||
if (!loaded) return 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
|
||||
if (verifying || (creditOkOrder && !credit)) {
|
||||
@@ -727,35 +762,40 @@ function ProposeQuestion() {
|
||||
);
|
||||
}
|
||||
|
||||
// Active credit — question form
|
||||
// Active credit
|
||||
if (credit) {
|
||||
const badge = credit.questionsLeft === null
|
||||
? "Ilimitadas"
|
||||
: `${credit.questionsLeft} restante${credit.questionsLeft !== 1 ? "s" : ""}`;
|
||||
const badge = `${credit.answersLeft} respuesta${credit.answersLeft !== 1 ? "s" : ""}`;
|
||||
return (
|
||||
<div className="propose">
|
||||
<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>
|
||||
</div>
|
||||
{sent && <p className="propose__msg propose__msg--ok">✓ ¡Enviada! Se usará en el próximo sorteo.</p>}
|
||||
{!exhausted ? (
|
||||
<form onSubmit={handleSubmitQuestion}>
|
||||
|
||||
{alreadySubmitted && submittedText && (
|
||||
<p className="propose__msg propose__msg--ok">
|
||||
✓ Tu respuesta: “{submittedText}”
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canAnswer && (
|
||||
<form onSubmit={handleSubmitAnswer}>
|
||||
<div className="propose__row">
|
||||
<textarea
|
||||
className="propose__textarea"
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
placeholder='"La peor cosa que puedes encontrar en ___"'
|
||||
placeholder="Tu respuesta más graciosa…"
|
||||
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"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="propose__hint">
|
||||
{text.length}/200 · mín. 10 ·{" "}
|
||||
{text.length}/150 ·{" "}
|
||||
<button type="button" className="propose__link-btn" onClick={() => {
|
||||
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||
setCredit(null);
|
||||
@@ -765,31 +805,39 @@ function ProposeQuestion() {
|
||||
</div>
|
||||
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
|
||||
</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">
|
||||
<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={() => {
|
||||
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||
setCredit(null);
|
||||
}}>Nuevo plan</button>
|
||||
}}>Recargar</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tier selection
|
||||
// Tier selection (purchase)
|
||||
return (
|
||||
<div className="propose">
|
||||
<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>
|
||||
{isKo && !koDismissed && (
|
||||
<p className="propose__msg propose__msg--error">
|
||||
El pago no se completó.{" "}
|
||||
<button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}>
|
||||
×
|
||||
</button>
|
||||
<button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}>×</button>
|
||||
</p>
|
||||
)}
|
||||
<div className="propose__tiers">
|
||||
@@ -1001,7 +1049,7 @@ function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProposeQuestion />
|
||||
<ProposeAnswer activeRound={state.active} />
|
||||
|
||||
<footer className="site-footer">
|
||||
<p>IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.</p>
|
||||
|
||||
1
game.ts
1
game.ts
@@ -67,6 +67,7 @@ export type RoundState = {
|
||||
viewerVotesA?: number;
|
||||
viewerVotesB?: number;
|
||||
viewerVotingEndsAt?: number;
|
||||
userAnswers?: { username: string; text: string }[];
|
||||
};
|
||||
|
||||
export type GameState = {
|
||||
|
||||
55
server.ts
55
server.ts
@@ -7,8 +7,7 @@ import broadcastHtml from "./broadcast.html";
|
||||
import preguntaHtml from "./pregunta.html";
|
||||
import {
|
||||
clearAllRounds, getRounds, getAllRounds,
|
||||
createPendingQuestion, markQuestionPaid,
|
||||
createPendingCredit, activateCredit, getCreditByOrder, consumeCreditQuestion,
|
||||
createPendingCredit, activateCredit, getCreditByOrder, submitUserAnswer,
|
||||
getPlayerScores,
|
||||
} from "./db.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 ADMIN_COOKIE = "argumentes_admin";
|
||||
|
||||
const CREDIT_TIERS: Record<string, { days: number; amount: number; label: string; maxQuestions: number | null }> = {
|
||||
basico: { days: 30, amount: 99, label: "10 preguntas", maxQuestions: 10 },
|
||||
pro: { days: 30, amount: 999, label: "200 preguntas", maxQuestions: 200 },
|
||||
ilimitado: { days: 30, amount: 1999, label: "Preguntas ilimitadas", maxQuestions: null },
|
||||
const CREDIT_TIERS: Record<string, { amount: number; label: string; maxAnswers: number }> = {
|
||||
basico: { amount: 99, label: "10 respuestas", maxAnswers: 10 },
|
||||
pro: { amount: 999, label: "300 respuestas", maxAnswers: 300 },
|
||||
full: { amount: 1999, label: "1000 respuestas", maxAnswers: 1000 },
|
||||
};
|
||||
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||
|
||||
@@ -561,7 +560,8 @@ const server = Bun.serve<WsData>({
|
||||
if (credit && credit.status === "pending") {
|
||||
const tierInfo = CREDIT_TIERS[credit.tier];
|
||||
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);
|
||||
if (activated) {
|
||||
log("INFO", "redsys", "Credit activated", {
|
||||
@@ -613,14 +613,14 @@ const server = Bun.serve<WsData>({
|
||||
|
||||
const tierInfo = CREDIT_TIERS[tier];
|
||||
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) {
|
||||
return new Response("El nombre debe tener entre 1 y 30 caracteres", { status: 400 });
|
||||
}
|
||||
|
||||
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 terminal = process.env.REDSYS_TERMINAL ?? "1";
|
||||
@@ -638,7 +638,7 @@ const server = Bun.serve<WsData>({
|
||||
urlOk: `${baseUrl}/?credito_ok=${orderId}`,
|
||||
urlKo: `${baseUrl}/?ko=1`,
|
||||
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
|
||||
productDescription: `Acceso argument.es — ${tierInfo.label}`,
|
||||
productDescription: `argument.es — ${tierInfo.label}`,
|
||||
});
|
||||
|
||||
log("INFO", "credito", "Credit purchase initiated", {
|
||||
@@ -664,8 +664,9 @@ const server = Bun.serve<WsData>({
|
||||
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
const questionsLeft =
|
||||
credit.maxQuestions === null ? null : credit.maxQuestions - credit.questionsUsed;
|
||||
const answersLeft = credit.maxQuestions === null
|
||||
? 0
|
||||
: credit.maxQuestions - credit.questionsUsed;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
found: true,
|
||||
@@ -676,7 +677,7 @@ const server = Bun.serve<WsData>({
|
||||
username: credit.username,
|
||||
expiresAt: credit.expiresAt,
|
||||
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") {
|
||||
return new Response("Method Not Allowed", {
|
||||
status: 405,
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -708,16 +709,26 @@ const server = Bun.serve<WsData>({
|
||||
if (!token) {
|
||||
return new Response("Token requerido", { status: 401 });
|
||||
}
|
||||
if (text.length < 10 || text.length > 200) {
|
||||
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 });
|
||||
if (text.length < 3 || text.length > 150) {
|
||||
return new Response("La respuesta debe tener entre 3 y 150 caracteres", { status: 400 });
|
||||
}
|
||||
|
||||
const result = consumeCreditQuestion(token, text);
|
||||
if (!result) {
|
||||
return new Response("Crédito no válido, expirado o sin preguntas disponibles", { status: 401 });
|
||||
const round = gameState.active;
|
||||
if (!round || !round.prompt) {
|
||||
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,
|
||||
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user