From bfdf5777f8e393de8750e963275d5655d18a475f Mon Sep 17 00:00:00 2001 From: directorfloo Date: Tue, 25 Aug 2026 09:45:24 +0100 Subject: [PATCH] feat(hunts): implement versioning for hunt edits with restore functionality --- apps/web/app/api/v1/hunts/[id]/route.ts | 33 ++++- .../[id]/versions/[version]/restore/route.ts | 35 ++++++ .../app/api/v1/hunts/[id]/versions/route.ts | 35 ++++++ apps/web/lib/db/huntVersions.ts | 117 ++++++++++++++++++ .../migrations/010_create_hunt_versions.sql | 17 +++ docs/api.md | 45 ++++++- docs/persistence-strategy.md | 10 ++ packages/types/src/api-schemas.ts | 19 +++ 8 files changed, 307 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/api/v1/hunts/[id]/versions/[version]/restore/route.ts create mode 100644 apps/web/app/api/v1/hunts/[id]/versions/route.ts create mode 100644 apps/web/lib/db/huntVersions.ts create mode 100644 apps/web/lib/db/migrations/010_create_hunt_versions.sql diff --git a/apps/web/app/api/v1/hunts/[id]/route.ts b/apps/web/app/api/v1/hunts/[id]/route.ts index 17260dda9..116e54613 100644 --- a/apps/web/app/api/v1/hunts/[id]/route.ts +++ b/apps/web/app/api/v1/hunts/[id]/route.ts @@ -1,9 +1,22 @@ import { NextResponse } from "next/server"; +import { z } from "zod"; import { getPublicHuntByIdOptimized } from "@/lib/db/queryOptimizer"; -import { NotFoundError, ValidationError } from "@/lib/api/errors"; +import { createHuntVersion } from "@/lib/db/huntVersions"; +import { ForbiddenError, NotFoundError, ValidationError } from "@/lib/api/errors"; import { withErrorHandling } from "@/lib/api/withErrorHandling"; +import { withValidation } from "@/lib/api/withValidation"; import { getIP, rateLimit, rateLimitResponse } from "@/lib/rate-limit"; +import { huntVersionEditBodySchema } from "@hunty/types/api-schemas"; + +const paramsSchema = z.object({ id: z.string() }); + +function assertCreator(snapshot: Record, actorAddress: string): void { + const creator = snapshot.creator ?? snapshot.ownerAddress; + if (typeof creator !== "string" || creator !== actorAddress) { + throw new ForbiddenError("Only the hunt creator can edit this hunt"); + } +} /** * GET /api/v1/hunts/[id] @@ -33,3 +46,21 @@ export const GET = withErrorHandling<{ params: Promise<{ id: string }> }>(async return NextResponse.json({ data: hunt }); }); + +/** + * PATCH /api/v1/hunts/[id] + * Store the submitted hunt snapshot as the next immutable version. + */ +export const PATCH = withValidation( + { body: huntVersionEditBodySchema, params: paramsSchema }, + async (_req, _context, { body, params }) => { + const huntId = Number(params!.id); + if (!Number.isInteger(huntId) || huntId <= 0 || body!.snapshot.id !== huntId) { + throw new ValidationError("Invalid hunt ID", { id: params!.id }); + } + + assertCreator(body!.snapshot, body!.actorAddress); + const version = await createHuntVersion(huntId, body!.snapshot, body!.actorAddress); + return NextResponse.json({ data: version }, { status: 201 }); + }, +); diff --git a/apps/web/app/api/v1/hunts/[id]/versions/[version]/restore/route.ts b/apps/web/app/api/v1/hunts/[id]/versions/[version]/restore/route.ts new file mode 100644 index 000000000..72af01e70 --- /dev/null +++ b/apps/web/app/api/v1/hunts/[id]/versions/[version]/restore/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { ForbiddenError, NotFoundError, ValidationError } from "@/lib/api/errors"; +import { withValidation } from "@/lib/api/withValidation"; +import { createHuntVersion, getHuntVersion } from "@/lib/db/huntVersions"; +import { huntVersionRestoreBodySchema } from "@hunty/types/api-schemas"; + +const paramsSchema = z.object({ id: z.string(), version: z.string() }); + +function parseParams(id: string, version: string): { huntId: number; version: number } { + const huntId = Number(id); + const versionNumber = Number(version); + if (!Number.isInteger(huntId) || huntId <= 0 || !Number.isInteger(versionNumber) || versionNumber <= 0) { + throw new ValidationError("Invalid hunt version", { id, version }); + } + return { huntId, version: versionNumber }; +} + +export const POST = withValidation( + { body: huntVersionRestoreBodySchema, params: paramsSchema }, + async (_req, _context, { body, params }) => { + const { huntId, version } = parseParams(params!.id, params!.version); + const selected = await getHuntVersion(huntId, version); + if (!selected) throw new NotFoundError("Hunt version not found", { huntId, version }); + + const creator = selected.snapshot.creator ?? selected.snapshot.ownerAddress; + if (creator !== body!.actorAddress) { + throw new ForbiddenError("Only the hunt creator can restore versions"); + } + + const restored = await createHuntVersion(huntId, selected.snapshot, body!.actorAddress); + return NextResponse.json({ data: restored, restoredFrom: version }); + }, +); diff --git a/apps/web/app/api/v1/hunts/[id]/versions/route.ts b/apps/web/app/api/v1/hunts/[id]/versions/route.ts new file mode 100644 index 000000000..bee02b619 --- /dev/null +++ b/apps/web/app/api/v1/hunts/[id]/versions/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { ForbiddenError, ValidationError } from "@/lib/api/errors"; +import { withValidation } from "@/lib/api/withValidation"; +import { getHuntVersion, listHuntVersions } from "@/lib/db/huntVersions"; +import { huntVersionsQuerySchema } from "@hunty/types/api-schemas"; + +const paramsSchema = z.object({ id: z.string() }); + +function parseHuntId(id: string): number { + const huntId = Number(id); + if (!Number.isInteger(huntId) || huntId <= 0) throw new ValidationError("Invalid hunt ID", { id }); + return huntId; +} + +function assertCreator(snapshot: Record, actorAddress: string): void { + const creator = snapshot.creator ?? snapshot.ownerAddress; + if (typeof creator !== "string" || creator !== actorAddress) { + throw new ForbiddenError("Only the hunt creator can manage versions"); + } +} + +export const GET = withValidation( + { query: huntVersionsQuerySchema, params: paramsSchema }, + async (_req, _context, { query, params }) => { + const huntId = parseHuntId(params!.id); + const versions = await listHuntVersions(huntId); + if (versions.length > 0) { + const latest = await getHuntVersion(huntId, versions[0].version); + if (latest) assertCreator(latest.snapshot, query!.actorAddress); + } + return NextResponse.json({ data: versions, retentionDays: 90 }); + }, +); diff --git a/apps/web/lib/db/huntVersions.ts b/apps/web/lib/db/huntVersions.ts new file mode 100644 index 000000000..bc40c49af --- /dev/null +++ b/apps/web/lib/db/huntVersions.ts @@ -0,0 +1,117 @@ +import { getDb } from "@/lib/db"; + +export const HUNT_VERSION_RETENTION_DAYS = 90; + +export type HuntSnapshot = Record; + +export interface HuntVersion { + huntId: number; + version: number; + snapshot: HuntSnapshot; + createdBy: string; + createdAt: string; +} + +export interface HuntVersionSummary { + huntId: number; + version: number; + createdBy: string; + createdAt: string; +} + +function toVersion(row: { + hunt_id: number; + version: number; + snapshot: HuntSnapshot; + created_by: string; + created_at: Date; +}): HuntVersion { + return { + huntId: row.hunt_id, + version: row.version, + snapshot: row.snapshot, + createdBy: row.created_by, + createdAt: row.created_at.toISOString(), + }; +} + +export async function createHuntVersion( + huntId: number, + snapshot: HuntSnapshot, + createdBy: string, +): Promise { + const sql = getDb(); + + return sql.begin(async (transaction) => { + await transaction` + SELECT pg_advisory_xact_lock(${huntId}) + `; + + const rows = await transaction<{ + hunt_id: number; + version: number; + snapshot: HuntSnapshot; + created_by: string; + created_at: Date; + }[]>` + INSERT INTO hunt_versions (hunt_id, version, snapshot, created_by) + VALUES ( + ${huntId}, + (SELECT COALESCE(MAX(version), 0) + 1 FROM hunt_versions WHERE hunt_id = ${huntId}), + ${sql.json(snapshot)}, + ${createdBy} + ) + RETURNING hunt_id, version, snapshot, created_by, created_at + `; + + await transaction` + DELETE FROM hunt_versions + WHERE created_at < NOW() - (${HUNT_VERSION_RETENTION_DAYS} * INTERVAL '1 day') + `; + + return toVersion(rows[0]); + }); +} + +export async function listHuntVersions(huntId: number): Promise { + const sql = getDb(); + const rows = await sql<{ + hunt_id: number; + version: number; + created_by: string; + created_at: Date; + }[]>` + SELECT hunt_id, version, created_by, created_at + FROM hunt_versions + WHERE hunt_id = ${huntId} + AND created_at >= NOW() - (${HUNT_VERSION_RETENTION_DAYS} * INTERVAL '1 day') + ORDER BY version DESC + `; + + return rows.map((row) => ({ + huntId: row.hunt_id, + version: row.version, + createdBy: row.created_by, + createdAt: row.created_at.toISOString(), + })); +} + +export async function getHuntVersion(huntId: number, version: number): Promise { + const sql = getDb(); + const rows = await sql<{ + hunt_id: number; + version: number; + snapshot: HuntSnapshot; + created_by: string; + created_at: Date; + }[]>` + SELECT hunt_id, version, snapshot, created_by, created_at + FROM hunt_versions + WHERE hunt_id = ${huntId} + AND version = ${version} + AND created_at >= NOW() - (${HUNT_VERSION_RETENTION_DAYS} * INTERVAL '1 day') + LIMIT 1 + `; + + return rows[0] ? toVersion(rows[0]) : undefined; +} diff --git a/apps/web/lib/db/migrations/010_create_hunt_versions.sql b/apps/web/lib/db/migrations/010_create_hunt_versions.sql new file mode 100644 index 000000000..e75d83baf --- /dev/null +++ b/apps/web/lib/db/migrations/010_create_hunt_versions.sql @@ -0,0 +1,17 @@ +-- Migration: retain creator hunt snapshots for version history and restore. +-- Snapshots are retained for 90 days; cleanup is also performed on writes. + +CREATE TABLE IF NOT EXISTS hunt_versions ( + hunt_id INTEGER NOT NULL, + version INTEGER NOT NULL, + snapshot JSONB NOT NULL, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (hunt_id, version) +); + +CREATE INDEX IF NOT EXISTS idx_hunt_versions_history + ON hunt_versions (hunt_id, version DESC); + +CREATE INDEX IF NOT EXISTS idx_hunt_versions_created_at + ON hunt_versions (created_at); diff --git a/docs/api.md b/docs/api.md index 41f212afe..c58997dbd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -7,8 +7,8 @@ This document describes the public REST API for Hunty. ## Authentication - **GET Endpoints**: Public, no authentication required. -- **Write Endpoints (POST/PUT/DELETE)**: Require an API key passed in the `X-API-Key` header. - *(Note: Current implementation only includes public GET endpoints)* +- **Write Endpoints**: Hunt version writes require the creator's `actorAddress` in the + validated request body. The server compares it with the snapshot creator address. ## Rate Limiting All API endpoints are subject to rate limiting. @@ -102,7 +102,46 @@ Returns detailed information about a specific hunt. - `404 Not Found`: If the hunt ID does not exist. - `403 Forbidden`: If the hunt is private. -### 3. Get Hunt Leaderboard +### 3. Version a Hunt Edit +`PATCH /hunts/[id]` + +Stores the submitted hunt snapshot as the next immutable version. The request must +include the creator wallet address and a snapshot whose `id` matches the URL. + +```json +{ + "actorAddress": "G...creator", + "snapshot": { + "id": 1, + "title": "Updated title", + "description": "Updated description", + "creator": "G...creator" + } +} +``` + +The response contains the assigned `version`, the stored snapshot, and its creation +timestamp. On-chain creation records remain immutable; this history versions the +mutable application snapshot. + +### 4. List Hunt Versions +`GET /hunts/[id]/versions?actorAddress=G...creator` + +Returns version metadata, newest first. History is retained for **90 days** from +creation, after which it is excluded and removed during subsequent version writes. + +### 5. Restore a Hunt Version +`POST /hunts/[id]/versions/[version]/restore` + +Restores a prior snapshot by creating a new version containing that snapshot. The +creator must provide `actorAddress`; clients should apply the returned `data.snapshot` +to their current hunt projection. + +```json +{ "actorAddress": "G...creator" } +``` + +### 6. Get Hunt Leaderboard `GET /hunts/[id]/leaderboard` Returns the paginated leaderboard for a specific hunt. diff --git a/docs/persistence-strategy.md b/docs/persistence-strategy.md index d57fc3fd0..5604af149 100644 --- a/docs/persistence-strategy.md +++ b/docs/persistence-strategy.md @@ -47,6 +47,7 @@ The PostgreSQL database (connection string in `DATABASE_URL`) is the canonical s | `003_create_moderation_tables.sql` | `moderation_queue`, `moderation_notifications` | Moderation review queue and creator notifications | | `004_create_anti_cheat_tables.sql` | `anti_cheat_answers`, `anti_cheat_anomalies`, `anti_cheat_bans`, `anti_cheat_tracking` | Answer history, anomaly detection, bans, per-key submission tracking | | `005_create_hunt_drafts.sql` | `hunt_drafts` | Cloud-synced creator draft auto-saves | +| `010_create_hunt_versions.sql` | `hunt_versions` | Immutable creator hunt snapshots for edit history and restore; retained for 90 days | | `008_create_analytics.sql` | `hunt_views`, `hint_usage_events` | Hunt view counters and hint-reveal event log (replaces `data/hunt-views.json`, `data/hint-usage.json`) | | `009_create_hunt_analytics.sql` | `hunt_analytics` | Per-hunt analytics: views, starts, completions, clue drop-off, demographics, time-series (replaces `data/hunt-analytics.json`) | @@ -77,6 +78,15 @@ Four tables replace four JSON files: `hunt_drafts` stores the full `HuntDraftSave` JSON payload keyed on `draft_id` and `owner_key` (wallet public key). The draft hook saves to `localStorage` immediately for offline-first UX, then syncs to `POST /api/v1/drafts` for logged-in users. +#### Hunt versions (`app/api/v1/hunts/[id]/versions/`) + +`hunt_versions` stores each creator-submitted hunt snapshot as an immutable JSONB +record with a per-hunt version number, creator address, and timestamp. The creator +can list versions and restore one; restore creates a new version rather than +rewriting history. Snapshots are retained for 90 days. Reads exclude older rows, +and the write path deletes expired rows; production deployments should also run +the same cleanup query from a scheduled maintenance job. + #### Hunt view analytics (`lib/analytics.ts`) `hunt_views` holds one row per hunt; `views` is incremented atomically via `INSERT … ON CONFLICT DO UPDATE`. `hint_usage_events` is an append-only log of hint-reveal events; wallet addresses are HMAC-hashed before storage so raw addresses are never persisted. diff --git a/packages/types/src/api-schemas.ts b/packages/types/src/api-schemas.ts index 546dfd71c..4203253b2 100644 --- a/packages/types/src/api-schemas.ts +++ b/packages/types/src/api-schemas.ts @@ -227,6 +227,25 @@ export const huntDeleteBodySchema = z.object({ confirmed: z.boolean().optional(), }) +// ─── v1 / Hunts / Versions ────────────────────────────────────────────────── + +export const huntSnapshotSchema = z.object({ + id: positiveIntSchema, +}).passthrough() + +export const huntVersionEditBodySchema = z.object({ + actorAddress: nonEmptyStringSchema, + snapshot: huntSnapshotSchema, +}) + +export const huntVersionRestoreBodySchema = z.object({ + actorAddress: nonEmptyStringSchema, +}) + +export const huntVersionsQuerySchema = z.object({ + actorAddress: nonEmptyStringSchema, +}) + // ─── v1 / Hunts / [id] / Collaborators ─────────────────────────────────────── export const collaboratorRoleSchema = z.enum(["editor", "viewer"])