From c3a62189255c86ee98a45af929c184ace8e8c59e Mon Sep 17 00:00:00 2001 From: Solomon Jay Date: Mon, 28 Sep 2026 20:38:49 +0000 Subject: [PATCH] fix: resolve issues #538 #511 #468 assigned to solomon35-stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #468: operation-labeled invariant tests for interleaved admin mutations (add/move/retire/set_question_per_level) including get_question_in_level enumeration and a drained-level case; fixes the broken lib.rs build by appending the missing Error variants QuestionRetired=15 and WrongQuestion=16 and aligning test discriminants (#14 was SchemaVersionMismatch). - #511: admin puzzle-submission form now persists through the backend draft API (create/update/publish), reports per-field validation errors, preserves content on failure; adds puzzleDraftService and vitest coverage. - #538: already satisfied on main by the extracted config schema; the fail-closed STELLAR_MODE=live default is documented and asserted by config-validation.spec.ts. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- frontend/app/admin/puzzle-submission/page.jsx | 193 ++++++++--- frontend/services/puzzleDraftService.js | 110 +++++++ .../tests/admin-puzzle-submission.test.jsx | 189 +++++++++++ onchain/contracts/stellar_hunts/src/lib.rs | 6 + onchain/contracts/stellar_hunts/src/test.rs | 305 +++++++++++++++++- 5 files changed, 757 insertions(+), 46 deletions(-) create mode 100644 frontend/services/puzzleDraftService.js create mode 100644 frontend/tests/admin-puzzle-submission.test.jsx diff --git a/frontend/app/admin/puzzle-submission/page.jsx b/frontend/app/admin/puzzle-submission/page.jsx index 2af36dbf..a4b1dffc 100644 --- a/frontend/app/admin/puzzle-submission/page.jsx +++ b/frontend/app/admin/puzzle-submission/page.jsx @@ -1,7 +1,14 @@ "use client"; import React, { useState } from "react"; -import { apiClient } from "../../../lib/api"; +import { useApiMutation } from "../../../hooks/useApiMutation"; +import { + buildDraftPayload, + createDraft, + extractFieldErrors, + publishDraft, + updateDraft, +} from "../../../services/puzzleDraftService"; const difficulties = ["Easy", "Medium", "Hard", "Expert"]; @@ -13,55 +20,139 @@ export default function AdminPuzzleSubmission() { difficulty: difficulties[0], nftMetadata: "", }); + // `null` when no draft exists yet; a string draft id once saved. Its + // presence switches the form between "create" and "update" mode. + const [draftId, setDraftId] = useState(null); const [status, setStatus] = useState(null); - const [submitting, setSubmitting] = useState(false); + const [fieldErrors, setFieldErrors] = useState({}); + // Local busy flag rather than the mutation's `isLoading`: it covers both + // mutations (including the save-then-publish fallback) and stays + // independent of the react-query result naming across versions. + const [busy, setBusy] = useState(false); const handleChange = (e) => { const { name, value } = e.target; setForm((prev) => ({ ...prev, [name]: value })); }; - const handleSubmit = async (e) => { + const resetAfterPublish = () => { + setForm({ + title: "", + description: "", + answer: "", + difficulty: difficulties[0], + nftMetadata: "", + }); + setDraftId(null); + }; + + // Shared mutation hook: consistent retry/error surface, no query cache to + // invalidate because draft state is local to this form. + const saveMutation = useApiMutation({ + fn: async ({ payload }) => { + if (draftId) { + return updateDraft(draftId, payload); + } + const created = await createDraft(payload); + setDraftId(created.id); + return created; + }, + onError: (error) => { + setFieldErrors(extractFieldErrors(error)); + setStatus("Failed to save draft."); + }, + }); + + const publishMutation = useApiMutation({ + fn: async () => { + if (!draftId) { + // The publish endpoint needs a persisted draft; save first. + const { payload, metadataError } = buildDraftPayload(form); + if (metadataError) { + const error = new Error(metadataError); + error.data = { message: [metadataError] }; + throw error; + } + const created = await createDraft(payload); + setDraftId(created.id); + return publishDraft(created.id); + } + return publishDraft(draftId); + }, + onError: (error) => { + setFieldErrors(extractFieldErrors(error)); + setStatus("Failed to publish draft."); + }, + }); + + const handleSave = async (e) => { e.preventDefault(); - setSubmitting(true); setStatus(null); - + setFieldErrors({}); + const { payload, metadataError } = buildDraftPayload(form); + if (metadataError) { + setFieldErrors({ form: metadataError }); + return; + } + setBusy(true); try { - await apiClient.post("/admin/puzzles", { - title: form.title.trim(), - description: form.description.trim(), - difficulty: form.difficulty.toLowerCase(), - hint: form.nftMetadata.trim() || undefined, - solution: form.answer.trim(), - isActive: true, - }); + await saveMutation.mutateAsync({ payload }); + } catch { + // The error surface (status + field errors) is set in onError. + } finally { + setBusy(false); + } + }; - setForm({ - title: "", - description: "", - answer: "", - difficulty: difficulties[0], - nftMetadata: "", - }); - setStatus("Puzzle submitted successfully."); - } catch (error) { - setStatus(error.message || "Failed to submit puzzle."); + const handlePublish = async () => { + setStatus(null); + setFieldErrors({}); + const { payload, metadataError } = buildDraftPayload(form); + if (metadataError) { + setFieldErrors({ form: metadataError }); + return; + } + setBusy(true); + try { + const published = await publishMutation.mutateAsync(); + if (published) { + setStatus("Draft published successfully."); + resetAfterPublish(); + } + } catch { + // The error surface (status + field errors) is set in onError. } finally { - setSubmitting(false); + setBusy(false); } }; + const submitting = busy || saveMutation.isLoading || publishMutation.isLoading; + // The entered content is never cleared on failure: the form state is only + // reset in `resetAfterPublish`, which runs after a successful publish. + const showError = (field) => + fieldErrors[field] ? ( +

{fieldErrors[field]}

+ ) : null; + return (
-

+

Submit New Puzzle

-
+ {draftId && ( +

+ Draft saved — you can update it or publish it. +

+ )} +
- + + {showError("title")}
-