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:
7
db.ts
7
db.ts
@@ -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
|
||||
|
||||
58
frontend.tsx
58
frontend.tsx
@@ -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) {
|
||||
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 };
|
||||
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: “{submittedText}”</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">
|
||||
|
||||
24
server.ts
24
server.ts
@@ -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 });
|
||||
}
|
||||
|
||||
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" },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user