Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 152 additions & 41 deletions frontend/app/admin/puzzle-submission/page.jsx
Original file line number Diff line number Diff line change
@@ -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"];

Expand All @@ -13,80 +20,167 @@ 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] ? (
<p className="mt-1 text-sm text-pink-400">{fieldErrors[field]}</p>
) : null;

return (
<main className="relative min-h-screen w-full overflow-hidden bg-gradient-to-br from-black via-purple-900 to-black flex items-center justify-center">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-purple-500/10 to-transparent opacity-20 pointer-events-none" />
<div className="backdrop-blur-lg bg-white/10 p-8 sm:p-12 rounded-2xl shadow-2xl border border-white/20 max-w-xl w-full text-center z-10">
<h1 className="text-3xl font-bold mb-6 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-pink-600">
<h1 className="text-3xl font-bold mb-2 bg-clip-text text-transparent bg-gradient-to-r from-purple-400 to-pink-600">
Submit New Puzzle
</h1>
<form onSubmit={handleSubmit} className="space-y-6 text-left">
{draftId && (
<p className="text-sm text-white/60 mb-4" data-testid="draft-state">
Draft saved — you can update it or publish it.
</p>
)}
<form onSubmit={handleSave} className="space-y-6 text-left">
<div>
<label className="block font-semibold mb-1 text-white">Title</label>
<label htmlFor="draft-title" className="block font-semibold mb-1 text-white">
Title
</label>
<input
id="draft-title"
type="text"
name="title"
value={form.title}
onChange={handleChange}
className="w-full border border-white/20 bg-transparent text-white rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
required
/>
{showError("title")}
</div>
<div>
<label className="block font-semibold mb-1 text-white">
<label htmlFor="draft-description" className="block font-semibold mb-1 text-white">
Description
</label>
<textarea
id="draft-description"
name="description"
value={form.description}
onChange={handleChange}
className="w-full border border-white/20 bg-transparent text-white rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
required
/>
{showError("description")}
</div>
<div>
<label className="block font-semibold mb-1 text-white">
<label htmlFor="draft-answer" className="block font-semibold mb-1 text-white">
Answer
</label>
<input
id="draft-answer"
type="text"
name="answer"
value={form.answer}
Expand All @@ -96,10 +190,11 @@ export default function AdminPuzzleSubmission() {
/>
</div>
<div>
<label className="block font-semibold mb-1 text-white">
<label htmlFor="draft-difficulty" className="block font-semibold mb-1 text-white">
Difficulty
</label>
<select
id="draft-difficulty"
name="difficulty"
value={form.difficulty}
onChange={handleChange}
Expand All @@ -117,24 +212,40 @@ export default function AdminPuzzleSubmission() {
</select>
</div>
<div>
<label className="block font-semibold mb-1 text-white">
NFT Metadata
<label htmlFor="draft-nft-metadata" className="block font-semibold mb-1 text-white">
NFT Metadata (JSON)
</label>
<textarea
id="draft-nft-metadata"
name="nftMetadata"
value={form.nftMetadata}
onChange={handleChange}
className="w-full border border-white/20 bg-transparent text-white rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
placeholder="Paste NFT metadata JSON here"
placeholder='{ "name": "Puzzle badge", "image": "..." }'
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 disabled:cursor-not-allowed disabled:opacity-70 text-white font-bold py-3 px-6 rounded-lg transform transition-all hover:scale-105 shadow-lg hover:shadow-purple-500/50"
>
{submitting ? "Submitting..." : "Submit Puzzle"}
</button>
<div className="flex gap-4">
<button
type="submit"
disabled={submitting}
className="flex-1 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600 disabled:cursor-not-allowed disabled:opacity-70 text-white font-bold py-3 px-6 rounded-lg transform transition-all hover:scale-105 shadow-lg hover:shadow-purple-500/50"
>
{saveMutation.isLoading
? "Saving..."
: draftId
? "Update Draft"
: "Save Draft"}
</button>
<button
type="button"
onClick={handlePublish}
disabled={submitting}
className="flex-1 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 disabled:cursor-not-allowed disabled:opacity-70 text-white font-bold py-3 px-6 rounded-lg transform transition-all hover:scale-105 shadow-lg hover:shadow-emerald-500/50"
>
{publishMutation.isLoading ? "Publishing..." : "Publish"}
</button>
</div>
{showError("form")}
{status && <p className="text-sm text-white/80">{status}</p>}
</form>
</div>
Expand Down
110 changes: 110 additions & 0 deletions frontend/services/puzzleDraftService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { apiClient, ApiError } from "@/lib/api";

/**
* Service for the backend puzzle-draft lifecycle (`backend/src/puzzle-draft/`).
*
* All routes are admin-only on the server (JwtAuthGuard + RolesGuard with
* `AdminRole.ADMIN`); this module intentionally performs no authorization
* logic of its own — the client-side check is advisory, the backend is the
* source of truth (issue #511).
*
* Endpoints (mounted under the versioned API prefix, see `lib/api.js`):
* - POST /drafts → create a draft
* - PATCH /drafts/:id → update a draft (rejected for published drafts)
* - POST /drafts/:id/publish → publish a draft
*/

/** Creates a draft from a `CreateDraftDto`-shaped payload. */
export async function createDraft(payload) {
const response = await apiClient.post("/drafts", payload);
return response.data;
}

/** Updates an existing draft from an `UpdateDraftDto`-shaped payload. */
export async function updateDraft(draftId, payload) {
const response = await apiClient.patch(`/drafts/${draftId}`, payload);
return response.data;
}

/** Publishes a draft through the backend publish endpoint. */
export async function publishDraft(draftId) {
const response = await apiClient.post(`/drafts/${draftId}/publish`);
return response.data;
}

/**
* Maps a backend validation rejection onto per-field errors.
*
* The global ValidationPipe returns class-validator messages as an array on
* `response.data.message` (e.g. `["title must be a string", ...]`). Each
* message is attributed to the first form field it mentions so the form can
* render the error next to the offending input; messages that name no known
* field are returned under `form`.
*
* @param {Error} error - an `ApiError` thrown by `apiClient` (or any error)
* @returns {Record<string, string>} field name → message
*/
export function extractFieldErrors(error) {
const messages = error?.data?.message ?? error?.response?.data?.message;
const list = Array.isArray(messages) ? messages : messages ? [messages] : [];

const knownFields = ["title", "description", "content"];
const fieldErrors = {};

for (const message of list) {
const text = typeof message === "string" ? message : String(message);
const match = knownFields.find((field) => text.includes(field));
const key = match ?? "form";
// Keep the first message per field; the rest are noise for the user.
if (!fieldErrors[key]) fieldErrors[key] = text;
}

return fieldErrors;
}

/**
* Builds the `CreateDraftDto` payload from the form state.
*
* The backend DTO contract is `{ title: string, description?: string,
* content: object }` (`backend/src/puzzle-draft/dto/create-draft.dto.ts`).
* The answer, difficulty and NFT metadata live inside the `content` object;
* the NFT metadata textarea is JSON text, so it is parsed (and its parse
* failure reported) here — serializing a string into an object is not
* re-declaring validation, which stays server-side.
*
* @returns {{ payload: object, metadataError?: string }}
*/
export function buildDraftPayload(form) {
const content = {
answer: form.answer.trim(),
difficulty: form.difficulty.toLowerCase(),
};

let metadataError;
const metadataText = form.nftMetadata.trim();
if (metadataText) {
try {
content.nftMetadata = JSON.parse(metadataText);
} catch {
metadataError = "NFT metadata must be valid JSON.";
}
}

const payload = {
title: form.title.trim(),
content,
};
if (form.description.trim()) {
payload.description = form.description.trim();
}

return { payload, metadataError };
}

export default {
createDraft,
updateDraft,
publishDraft,
extractFieldErrors,
buildDraftPayload,
};
Loading
Loading