feat: admin can answer questions without paying for testing

- Server: /api/respuesta/enviar checks admin cookie; if authorized,
  bypasses credit check and stores answer via insertAdminAnswer()
- DB: insertAdminAnswer() inserts directly into user_answers with
  username='Admin', skipping the credit budget entirely
- Frontend: ProposeAnswer checks /api/admin/status on mount; if admin
  is logged in, shows the answer form directly (orange Admin badge)
  instead of the payment tier selection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 18:15:46 +01:00
parent f9a8e2544f
commit fe5bb5a5c2
3 changed files with 88 additions and 17 deletions

7
db.ts
View File

@@ -189,6 +189,13 @@ export function getCreditByOrder(orderId: string): {
} | null;
}
/** Insert a user answer directly, bypassing credit checks (admin use). */
export function insertAdminAnswer(roundNum: number, text: string, username: string): void {
db.prepare(
"INSERT INTO user_answers (round_num, text, username, token) VALUES ($roundNum, $text, $username, 'admin')"
).run({ $roundNum: roundNum, $text: text, $username: username });
}
/**
* Atomically validates a credit token, records a user answer for the given
* round, and decrements the answer budget. Returns null if the token is

View File

@@ -621,6 +621,7 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
const [credit, setCredit] = useState<CreditInfo | null>(null);
const [loaded, setLoaded] = useState(false);
const [isAdmin, setIsAdmin] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState(false);
const [selectedTier, setSelectedTier] = useState<ProposeTierId | null>(null);
@@ -637,6 +638,10 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
useEffect(() => {
setCredit(loadCredit());
setLoaded(true);
// Check if admin is logged in (cookie-based, no token needed)
fetch("/api/admin/status")
.then(r => { if (r.ok) setIsAdmin(true); })
.catch(() => {});
}, []);
// Clear submission state when a new round starts
@@ -704,28 +709,33 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
async function handleSubmitAnswer(e: React.FormEvent) {
e.preventDefault();
if (!credit || !activeRound) return;
if (!credit && !isAdmin) return;
if (!activeRound) return;
setSubmitError(null);
setSubmitting(true);
try {
const res = await fetch("/api/respuesta/enviar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), token: credit.token }),
body: JSON.stringify({ text: text.trim(), token: credit?.token ?? "" }),
});
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
if (!isAdmin) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}
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; answersLeft: number };
const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
setCredit(updated);
if (credit) {
const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
setCredit(updated);
}
setSubmittedFor(activeRound.num);
setSubmittedText(text.trim());
setText("");
@@ -742,8 +752,8 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
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");
const phaseOk = activeRound?.phase === "answering" || activeRound?.phase === "prompting";
const canAnswer = (isAdmin || (credit && !exhausted)) && hasPrompt && !alreadySubmitted && phaseOk;
// Verifying payment
if (verifying || (creditOkOrder && !credit)) {
@@ -828,6 +838,46 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
);
}
// Admin: show answer form without requiring credit
if (isAdmin) {
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Responde junto a las IAs</span>
<span className="propose__badge" style={{ color: "var(--accent)", borderColor: "var(--accent)", background: "rgba(217,119,87,0.1)" }}>Admin</span>
</div>
{alreadySubmitted && submittedText && (
<p className="propose__msg propose__msg--ok"> Tu respuesta: &ldquo;{submittedText}&rdquo;</p>
)}
{canAnswer && (
<form onSubmit={handleSubmitAnswer}>
<div className="propose__row">
<textarea
className="propose__textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder="Tu respuesta más graciosa…"
rows={2}
maxLength={150}
autoFocus
/>
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 3}>
{submitting ? "…" : "Enviar"}
</button>
</div>
<div className="propose__hint">{text.length}/150</div>
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
</form>
)}
{!canAnswer && !alreadySubmitted && (
<p className="propose__msg" style={{ color: "var(--text-muted)" }}>
{!hasPrompt ? "Esperando la siguiente pregunta…" : "Ya no se aceptan respuestas para esta ronda."}
</p>
)}
</div>
);
}
// Tier selection (purchase)
return (
<div className="propose">

View File

@@ -7,7 +7,8 @@ import broadcastHtml from "./broadcast.html";
import preguntaHtml from "./pregunta.html";
import {
clearAllRounds, getRounds, getAllRounds,
createPendingCredit, activateCredit, getCreditByOrder, submitUserAnswer,
createPendingCredit, activateCredit, getCreditByOrder,
submitUserAnswer, insertAdminAnswer,
getPlayerScores,
} from "./db.ts";
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
@@ -706,7 +707,9 @@ const server = Bun.serve<WsData>({
return new Response("Invalid JSON body", { status: 400 });
}
if (!token) {
const adminMode = isAdminAuthorized(req, url);
if (!token && !adminMode) {
return new Response("Token requerido", { status: 401 });
}
if (text.length < 3 || text.length > 150) {
@@ -718,17 +721,28 @@ const server = Bun.serve<WsData>({
return new Response("No hay ronda activa", { status: 409 });
}
const result = submitUserAnswer(token, round.num, text);
if (!result) {
return new Response("Crédito no válido o sin respuestas disponibles", { status: 401 });
let username: string;
let answersLeft: number;
if (adminMode) {
username = "Admin";
insertAdminAnswer(round.num, text, username);
answersLeft = 999;
} else {
const result = submitUserAnswer(token, round.num, text);
if (!result) {
return new Response("Crédito no válido o sin respuestas disponibles", { status: 401 });
}
username = result.username;
answersLeft = result.answersLeft;
}
// Add to live round state and broadcast
round.userAnswers = [...(round.userAnswers ?? []), { username: result.username, text }];
round.userAnswers = [...(round.userAnswers ?? []), { 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 }), {
log("INFO", "respuesta", "Answer submitted", { username, round: round.num, admin: adminMode, ip });
return new Response(JSON.stringify({ ok: true, answersLeft }), {
status: 200,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});