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
33 changes: 32 additions & 1 deletion apps/web/app/api/v1/hunts/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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]
Expand Down Expand Up @@ -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 });
},
);
35 changes: 35 additions & 0 deletions apps/web/app/api/v1/hunts/[id]/versions/[version]/restore/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
},
);
35 changes: 35 additions & 0 deletions apps/web/app/api/v1/hunts/[id]/versions/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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 });
},
);
117 changes: 117 additions & 0 deletions apps/web/lib/db/huntVersions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { getDb } from "@/lib/db";

export const HUNT_VERSION_RETENTION_DAYS = 90;

export type HuntSnapshot = Record<string, unknown>;

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<HuntVersion> {
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<HuntVersionSummary[]> {
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<HuntVersion | undefined> {
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;
}
17 changes: 17 additions & 0 deletions apps/web/lib/db/migrations/010_create_hunt_versions.sql
Original file line number Diff line number Diff line change
@@ -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);
45 changes: 42 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/persistence-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) |

Expand Down Expand Up @@ -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.
Expand Down
19 changes: 19 additions & 0 deletions packages/types/src/api-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down