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;
|
} | 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
|
* Atomically validates a credit token, records a user answer for the given
|
||||||
* round, and decrements the answer budget. Returns null if the token is
|
* round, and decrements the answer budget. Returns null if the token is
|
||||||
|
|||||||
68
frontend.tsx
68
frontend.tsx
@@ -621,6 +621,7 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
|
|||||||
|
|
||||||
const [credit, setCredit] = useState<CreditInfo | null>(null);
|
const [credit, setCredit] = useState<CreditInfo | null>(null);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [isAdmin, setIsAdmin] = useState(false);
|
||||||
const [verifying, setVerifying] = useState(false);
|
const [verifying, setVerifying] = useState(false);
|
||||||
const [verifyError, setVerifyError] = useState(false);
|
const [verifyError, setVerifyError] = useState(false);
|
||||||
const [selectedTier, setSelectedTier] = useState<ProposeTierId | null>(null);
|
const [selectedTier, setSelectedTier] = useState<ProposeTierId | null>(null);
|
||||||
@@ -637,6 +638,10 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCredit(loadCredit());
|
setCredit(loadCredit());
|
||||||
setLoaded(true);
|
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
|
// Clear submission state when a new round starts
|
||||||
@@ -704,28 +709,33 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
|
|||||||
|
|
||||||
async function handleSubmitAnswer(e: React.FormEvent) {
|
async function handleSubmitAnswer(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!credit || !activeRound) return;
|
if (!credit && !isAdmin) return;
|
||||||
|
if (!activeRound) return;
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/respuesta/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 ?? "" }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
if (!isAdmin) {
|
||||||
setCredit(null);
|
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||||
|
setCredit(null);
|
||||||
|
}
|
||||||
throw new Error("Crédito agotado o no válido.");
|
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.");
|
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; answersLeft: number };
|
const data = await res.json() as { ok: boolean; answersLeft: number };
|
||||||
const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
|
if (credit) {
|
||||||
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
|
const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
|
||||||
setCredit(updated);
|
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
setCredit(updated);
|
||||||
|
}
|
||||||
setSubmittedFor(activeRound.num);
|
setSubmittedFor(activeRound.num);
|
||||||
setSubmittedText(text.trim());
|
setSubmittedText(text.trim());
|
||||||
setText("");
|
setText("");
|
||||||
@@ -742,8 +752,8 @@ function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
|
|||||||
const exhausted = credit !== null && credit.answersLeft <= 0;
|
const exhausted = credit !== null && credit.answersLeft <= 0;
|
||||||
const hasPrompt = !!(activeRound?.prompt);
|
const hasPrompt = !!(activeRound?.prompt);
|
||||||
const alreadySubmitted = submittedFor === activeRound?.num;
|
const alreadySubmitted = submittedFor === activeRound?.num;
|
||||||
const canAnswer = credit && !exhausted && hasPrompt && !alreadySubmitted &&
|
const phaseOk = activeRound?.phase === "answering" || activeRound?.phase === "prompting";
|
||||||
(activeRound?.phase === "answering" || activeRound?.phase === "prompting");
|
const canAnswer = (isAdmin || (credit && !exhausted)) && hasPrompt && !alreadySubmitted && phaseOk;
|
||||||
|
|
||||||
// Verifying payment
|
// Verifying payment
|
||||||
if (verifying || (creditOkOrder && !credit)) {
|
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)
|
// Tier selection (purchase)
|
||||||
return (
|
return (
|
||||||
<div className="propose">
|
<div className="propose">
|
||||||
|
|||||||
30
server.ts
30
server.ts
@@ -7,7 +7,8 @@ import broadcastHtml from "./broadcast.html";
|
|||||||
import preguntaHtml from "./pregunta.html";
|
import preguntaHtml from "./pregunta.html";
|
||||||
import {
|
import {
|
||||||
clearAllRounds, getRounds, getAllRounds,
|
clearAllRounds, getRounds, getAllRounds,
|
||||||
createPendingCredit, activateCredit, getCreditByOrder, submitUserAnswer,
|
createPendingCredit, activateCredit, getCreditByOrder,
|
||||||
|
submitUserAnswer, insertAdminAnswer,
|
||||||
getPlayerScores,
|
getPlayerScores,
|
||||||
} from "./db.ts";
|
} from "./db.ts";
|
||||||
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.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 });
|
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 });
|
return new Response("Token requerido", { status: 401 });
|
||||||
}
|
}
|
||||||
if (text.length < 3 || text.length > 150) {
|
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 });
|
return new Response("No hay ronda activa", { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = submitUserAnswer(token, round.num, text);
|
let username: string;
|
||||||
if (!result) {
|
let answersLeft: number;
|
||||||
return new Response("Crédito no válido o sin respuestas disponibles", { status: 401 });
|
|
||||||
|
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
|
// Add to live round state and broadcast
|
||||||
round.userAnswers = [...(round.userAnswers ?? []), { username: result.username, text }];
|
round.userAnswers = [...(round.userAnswers ?? []), { username, text }];
|
||||||
broadcast();
|
broadcast();
|
||||||
|
|
||||||
log("INFO", "respuesta", "User answer submitted", { username: result.username, round: round.num, ip });
|
log("INFO", "respuesta", "Answer submitted", { username, round: round.num, admin: adminMode, ip });
|
||||||
return new Response(JSON.stringify({ ok: true, answersLeft: result.answersLeft }), {
|
return new Response(JSON.stringify({ ok: true, answersLeft }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user