diff --git a/apps/web/app/api/v1/hunts/[id]/collaborators/presence/route.ts b/apps/web/app/api/v1/hunts/[id]/collaborators/presence/route.ts new file mode 100644 index 000000000..501614dcf --- /dev/null +++ b/apps/web/app/api/v1/hunts/[id]/collaborators/presence/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server" +import { rateLimit, getIP, rateLimitResponse } from "@/lib/rate-limit" +import { ValidationError } from "@/lib/api/errors" +import { withErrorHandling } from "@/lib/api/withErrorHandling" +import { withValidation } from "@/lib/api/withValidation" +import { + dbGetActiveEditors, + dbGetCollaborators, + dbPingPresence, + dbGetRoleForWallet, +} from "@/lib/collaborationDb" +import { presencePingBodySchema, presenceQuerySchema } from "@hunty/types/api-schemas" +import { z } from "zod" + +type RouteContext = { params: Promise<{ id: string }> } + +const paramsSchema = z.object({ id: z.string() }) + +function parseHuntId(id: string): number | null { + const n = Number(id) + return Number.isFinite(n) && n > 0 ? n : null +} + +/** + * GET /api/v1/hunts/:id/collaborators/presence + * Returns active editors for a hunt. + */ +export const GET = withValidation( + { query: presenceQuerySchema, params: paramsSchema }, + async (_req, context, { query, params }) => { + const huntId = parseHuntId(params!.id) + if (huntId == null) { + throw new ValidationError("Invalid hunt id", { id: params!.id }) + } + + const activeEditors = await dbGetActiveEditors( + huntId, + query.walletAddress, + query.staleMs, + ) + + return NextResponse.json({ activeEditors }) + } +) + +/** + * POST /api/v1/hunts/:id/collaborators/presence + * Ping presence with optional editing field. + */ +export const POST = withValidation( + { body: presencePingBodySchema, params: paramsSchema }, + async (req, _context, { body, params }) => { + const ip = getIP(req) + const { success, reset } = await rateLimit(ip, { limit: 120, windowMs: 60_000 }) + if (!success) return rateLimitResponse(reset) + + const huntId = parseHuntId(params!.id) + if (huntId == null) { + throw new ValidationError("Invalid hunt id", { id: params!.id }) + } + + await dbPingPresence(huntId, body.walletAddress, body.editingField ?? undefined) + + const role = await dbGetRoleForWallet(huntId, body.walletAddress) + return NextResponse.json({ ok: true, role }) + } +) diff --git a/apps/web/app/api/v1/hunts/[id]/collaborators/presence/stream/route.ts b/apps/web/app/api/v1/hunts/[id]/collaborators/presence/stream/route.ts new file mode 100644 index 000000000..c7f47d7b0 --- /dev/null +++ b/apps/web/app/api/v1/hunts/[id]/collaborators/presence/stream/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server" +import { getIP, rateLimit, rateLimitResponse } from "@/lib/rate-limit" +import { ValidationError } from "@/lib/api/errors" +import { withErrorHandling } from "@/lib/api/withErrorHandling" +import { dbGetActiveEditors } from "@/lib/collaborationDb" + +type RouteContext = { params: Promise<{ id: string }> } + +function parseHuntId(id: string): number | null { + const n = Number(id) + return Number.isFinite(n) && n > 0 ? n : null +} + +/** + * GET /api/v1/hunts/:id/collaborators/presence/stream + * SSE stream that emits active editors whenever the set changes. + * + * Polls the DB every 2s and emits an event when the active editor list changes. + */ +export const GET = withErrorHandling(async (req: Request, context: RouteContext) => { + const ip = getIP(req) + const { success, reset } = await rateLimit(ip, { limit: 30, windowMs: 60_000 }) + if (!success) return rateLimitResponse(reset) + + const { id } = await context.params + const huntId = parseHuntId(id) + if (huntId == null) { + throw new ValidationError("Invalid hunt id", { id }) + } + + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder() + let lastPayload = "" + + const send = (data: unknown) => { + const payload = JSON.stringify(data) + if (payload === lastPayload) return + lastPayload = payload + const message = `data: ${payload}\n\n` + controller.enqueue(encoder.encode(message)) + } + + send({ type: "connected", huntId }) + + const interval = setInterval(async () => { + try { + const editors = await dbGetActiveEditors(huntId) + send({ type: "update", activeEditors: editors }) + } catch { + send({ type: "error", message: "Failed to fetch presence" }) + } + }, 2000) + + req.signal.addEventListener("abort", () => { + clearInterval(interval) + controller.close() + }) + }, + }) + + return new NextResponse(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }) +}) diff --git a/apps/web/app/api/v1/hunts/[id]/collaborators/route.ts b/apps/web/app/api/v1/hunts/[id]/collaborators/route.ts index c6937bc7e..6fae7b33f 100644 --- a/apps/web/app/api/v1/hunts/[id]/collaborators/route.ts +++ b/apps/web/app/api/v1/hunts/[id]/collaborators/route.ts @@ -13,6 +13,19 @@ import { transferOwnership, updateCollaboratorRole, } from "@/lib/collaboration" +import { + dbAcceptInvite, + dbEnsureOwner, + dbGetActiveEditors, + dbGetCollaborators, + dbGetRoleForWallet, + dbInviteCollaborator, + dbPingPresence, + dbRemoveCollaborator, + dbSaveCollaborators, + dbTransferOwnership, + dbUpdateCollaboratorRole, +} from "@/lib/collaborationDb" import { collaboratorsBodySchema } from "@hunty/types/api-schemas" import { z } from "zod" @@ -40,8 +53,9 @@ export const GET = withErrorHandling(async (req: Request, context: RouteContext) throw new ValidationError("Invalid hunt id", { id }) } + const collaborators = await dbGetCollaborators(huntId) return NextResponse.json({ - collaborators: getCollaborators(huntId), + collaborators, activity: getActivityLog(huntId, 50), }) }) @@ -64,32 +78,33 @@ export const POST = withValidation( switch (body.action) { case "ensure_owner": { - const owner = ensureOwner(huntId, body.actorAddress) + const owner = await dbEnsureOwner(huntId, body.actorAddress) + await dbSaveCollaborators(huntId, [owner, ...(await dbGetCollaborators(huntId)).filter((c) => c.walletAddress !== body.actorAddress)]) return NextResponse.json({ ok: true, collaborator: owner }) } case "invite": { const role = body.role === "viewer" ? "viewer" : "editor" - const result = inviteCollaborator(huntId, body.actorAddress, body.walletAddress, role) + const result = await dbInviteCollaborator(huntId, body.actorAddress, body.walletAddress, role) if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 }) return NextResponse.json({ ok: true, collaborator: result.collaborator }) } case "accept": { - const ok = acceptInvite(huntId, body.actorAddress) + const ok = await dbAcceptInvite(huntId, body.actorAddress) if (!ok) return NextResponse.json({ error: "Invite not found" }, { status: 404 }) return NextResponse.json({ ok: true }) } case "update_role": { - const result = updateCollaboratorRole(huntId, body.actorAddress, body.walletAddress, body.role) + const result = await dbUpdateCollaboratorRole(huntId, body.actorAddress, body.walletAddress, body.role) if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 }) return NextResponse.json({ ok: true, collaborator: result.collaborator }) } case "remove": { - const result = removeCollaborator(huntId, body.actorAddress, body.walletAddress) + const result = await dbRemoveCollaborator(huntId, body.actorAddress, body.walletAddress) if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 }) return NextResponse.json({ ok: true }) } case "transfer": { - const result = transferOwnership(huntId, body.actorAddress, body.newOwnerAddress) + const result = await dbTransferOwnership(huntId, body.actorAddress, body.newOwnerAddress) if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 }) return NextResponse.json({ ok: true }) } diff --git a/apps/web/components/CollaboratorsPanel.tsx b/apps/web/components/CollaboratorsPanel.tsx index c525a45f2..0736685ad 100644 --- a/apps/web/components/CollaboratorsPanel.tsx +++ b/apps/web/components/CollaboratorsPanel.tsx @@ -1,18 +1,10 @@ "use client" -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Crown, Eye, Pencil, UserPlus, Users } from "lucide-react" import { - acceptInvite, appendActivity, - getActiveEditors, getActivityLog, - getCollaborators, - inviteCollaborator, - pingPresence, - removeCollaborator, - transferOwnership, - updateCollaboratorRole, type CollaboratorRole, type CollaborationActivityEntry, type HuntCollaborator, @@ -20,6 +12,7 @@ import { import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { cn } from "@/lib/utils" +import { toast } from "sonner" interface CollaboratorsPanelProps { huntId: number @@ -40,56 +33,115 @@ export function CollaboratorsPanel({ }: CollaboratorsPanelProps) { const [collaborators, setCollaborators] = useState([]) const [activity, setActivity] = useState([]) + const [activeEditors, setActiveEditors] = useState([]) const [inviteAddress, setInviteAddress] = useState("") const [inviteRole, setInviteRole] = useState<"editor" | "viewer">("editor") const [error, setError] = useState(null) const [message, setMessage] = useState(null) + const eventSourceRef = useRef(null) - const refresh = useCallback(() => { - setCollaborators(getCollaborators(huntId)) - setActivity(getActivityLog(huntId, 30)) + const refresh = useCallback(async () => { + try { + const res = await fetch(`/api/v1/hunts/${huntId}/collaborators`) + if (!res.ok) throw new Error("Failed to fetch collaborators") + const data = await res.json() + setCollaborators(data.collaborators) + setActivity(data.activity) + } catch { + // silent + } }, [huntId]) useEffect(() => { refresh() }, [refresh]) - // Presence heartbeat while panel is open useEffect(() => { if (!currentWallet) return - pingPresence(huntId, currentWallet, "collaborators-panel") - const id = setInterval(() => { - pingPresence(huntId, currentWallet, "collaborators-panel") - refresh() + + const ping = async (editingField?: string | null) => { + try { + await fetch(`/api/v1/hunts/${huntId}/collaborators/presence`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + walletAddress: currentWallet, + editingField: editingField ?? null, + }), + }) + } catch { + // silent + } + } + + ping("collaborators-panel") + const interval = setInterval(() => { + ping("collaborators-panel") }, 8_000) + return () => { - clearInterval(id) - pingPresence(huntId, currentWallet, null) + clearInterval(interval) + ping(null) } - }, [huntId, currentWallet, refresh]) + }, [huntId, currentWallet]) + + useEffect(() => { + if (!currentWallet) return + const es = new EventSource(`/api/v1/hunts/${huntId}/collaborators/presence/stream`) + eventSourceRef.current = es + + es.addEventListener("update", ((e: MessageEvent) => { + try { + const data = JSON.parse(e.data) + if (data.activeEditors) { + setActiveEditors(data.activeEditors) + } + } catch { + // ignore parse errors + } + }) as EventListener) + + es.onerror = () => { + es.close() + } + + return () => { + es.close() + eventSourceRef.current = null + } + }, [huntId, currentWallet]) const me = useMemo( () => collaborators.find((c) => c.walletAddress === currentWallet), [collaborators, currentWallet], ) const isOwner = me?.role === "owner" - const activeEditors = useMemo(() => { - // Reference collaborators so re-fetching collaborators triggers recomputing active editors - void collaborators - return getActiveEditors(huntId, currentWallet) - }, [huntId, currentWallet, collaborators]) - const handleInvite = () => { + const handleInvite = async () => { setError(null) setMessage(null) - const result = inviteCollaborator(huntId, currentWallet, inviteAddress, inviteRole) - if (!result.ok) { - setError(result.error) - return + try { + const res = await fetch(`/api/v1/hunts/${huntId}/collaborators`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "invite", + actorAddress: currentWallet, + walletAddress: inviteAddress, + role: inviteRole, + }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error) + return + } + setInviteAddress("") + setMessage(`Invite sent to ${data.collaborator.walletAddress.slice(0, 6)}…`) + await refresh() + } catch { + setError("Network error") } - setInviteAddress("") - setMessage(`Invite sent to ${result.collaborator.walletAddress.slice(0, 6)}…`) - refresh() } return ( @@ -140,14 +192,24 @@ export function CollaboratorsPanel({ + {csvFileName && ( +

Selected: {csvFileName}

+ )} + + {csvPreview && ( +
+

+ {csvPreview.rows.length} row(s) parsed, {csvPreview.errors.length} error(s) +

+
+ + + + + + + + + + + + + + + {csvPreview.rows.map((row, idx) => { + const rowErrors = csvPreview.errors.filter((e) => e.row === idx + 1) + const isValid = rowErrors.length === 0 + return ( + + + + + + + + + + + ) + })} + +
#QuestionAnswerPtsHintCostDiffStatus
{idx + 1}{row.question}{row.answer}{row.points}{row.hint || "—"}{row.hintCost ?? 0}{row.difficulty || "—"} + {isValid ? ( + Valid + ) : ( + {rowErrors.map((e) => e.message).join(", ")} + )} +
+
+ {csvPreview.errors.length > 0 && ( +

+ Fix the highlighted rows before importing, or note that invalid rows will be skipped. +

+ )} +
+ )} + + + + + + + + +
diff --git a/apps/web/lib/__tests__/csv.test.ts b/apps/web/lib/__tests__/csv.test.ts new file mode 100644 index 000000000..59fccdb9b --- /dev/null +++ b/apps/web/lib/__tests__/csv.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest" +import { parseClueCsv } from "@/lib/csv" + +describe("parseClueCsv", () => { + it("parses a simple CSV without header", () => { + const result = parseClueCsv("What is 2+2?,4,10") + expect(result.rows).toHaveLength(1) + expect(result.rows[0]).toMatchObject({ + question: "What is 2+2?", + answer: "4", + points: 10, + }) + expect(result.errors).toHaveLength(0) + }) + + it("parses a CSV with header", () => { + const result = parseClueCsv("question,answer,points\nCapital of France?,Paris,20") + expect(result.rows).toHaveLength(1) + expect(result.rows[0]).toMatchObject({ + question: "Capital of France?", + answer: "Paris", + points: 20, + }) + }) + + it("parses all columns including optional ones", () => { + const result = parseClueCsv("Q?,A,15,Look up,Easy") + expect(result.rows).toHaveLength(1) + expect(result.rows[0]).toMatchObject({ + question: "Q?", + answer: "A", + points: 15, + hint: "Look up", + hintCost: 0, + difficulty: "Easy", + }) + }) + + it("handles quoted fields with commas", () => { + const result = parseClueCsv('"What is the capital of France?","Paris, France",10') + expect(result.rows).toHaveLength(1) + expect(result.rows[0].question).toBe("What is the capital of France?") + expect(result.rows[0].answer).toBe("Paris, France") + }) + + it("collects errors per row", () => { + const result = parseClueCsv(",,-1") + expect(result.rows).toHaveLength(1) + expect(result.errors.map((e) => e.message)).toEqual( + expect.arrayContaining([ + expect.stringContaining("Question is required"), + expect.stringContaining("Answer is required"), + expect.stringContaining("Points must be a positive integer"), + ]) + ) + }) + + it("defaults points to 10 when missing and not invalid", () => { + const result = parseClueCsv("Question,Answer") + expect(result.rows).toHaveLength(1) + expect(result.rows[0].points).toBe(10) + }) + + it("skips empty lines", () => { + const result = parseClueCsv("Q,A,5\n\nQ2,A2,10") + expect(result.rows).toHaveLength(2) + }) +}) diff --git a/apps/web/lib/collaborationDb.ts b/apps/web/lib/collaborationDb.ts new file mode 100644 index 000000000..eaa3f72f5 --- /dev/null +++ b/apps/web/lib/collaborationDb.ts @@ -0,0 +1,379 @@ +/** + * PostgreSQL-backed collaboration store. + * + * Replaces the ephemeral localStorage/memory stores in lib/collaboration.ts + * with durable, multi-instance-safe database operations. + */ + +import { getDb } from "@/lib/db" +import type { CollaboratorRole, HuntCollaborator } from "@/lib/collaboration" + +const COLLAB_KEY = "hunty_collaborators" + +function toRole(role: string): CollaboratorRole { + return role as CollaboratorRole +} + +export async function dbGetCollaborators(huntId: number): Promise { + const sql = getDb() + const rows = await sql` + SELECT wallet_address, role, invited_at, invited_by, accepted, last_active_at, editing_field + FROM hunt_collaborators + WHERE hunt_id = ${huntId} + ORDER BY invited_at ASC + ` + return rows.map((r) => ({ + walletAddress: r.wallet_address, + role: toRole(r.role), + invitedAt: r.invited_at, + invitedBy: r.invited_by, + accepted: r.accepted, + lastActiveAt: r.last_active_at ?? undefined, + editingField: r.editing_field ?? null, + })) +} + +export async function dbSaveCollaborators(huntId: number, list: HuntCollaborator[]): Promise { + const sql = getDb() + await sql`DELETE FROM hunt_collaborators WHERE hunt_id = ${huntId}` + for (const c of list) { + await sql` + INSERT INTO hunt_collaborators (hunt_id, wallet_address, role, invited_at, invited_by, accepted, last_active_at, editing_field) + VALUES (${huntId}, ${c.walletAddress}, ${c.role}, ${c.invitedAt}, ${c.invitedBy}, ${c.accepted}, ${c.lastActiveAt ?? null}, ${c.editingField ?? null}) + ON CONFLICT (hunt_id, wallet_address) DO UPDATE SET + role = EXCLUDED.role, + accepted = EXCLUDED.accepted, + last_active_at = EXCLUDED.last_active_at, + editing_field = EXCLUDED.editing_field, + updated_at = NOW() + ` + } +} + }) +} + +export async function dbUpsertCollaborator(huntId: number, collaborator: HuntCollaborator): Promise { + const sql = getDb() + await sql` + INSERT INTO hunt_collaborators (hunt_id, wallet_address, role, invited_at, invited_by, accepted, last_active_at, editing_field) + VALUES (${huntId}, ${collaborator.walletAddress}, ${collaborator.role}, ${collaborator.invitedAt}, ${collaborator.invitedBy}, ${collaborator.accepted}, ${collaborator.lastActiveAt ?? null}, ${collaborator.editingField ?? null}) + ON CONFLICT (hunt_id, wallet_address) DO UPDATE SET + role = EXCLUDED.role, + accepted = EXCLUDED.accepted, + last_active_at = EXCLUDED.last_active_at, + editing_field = EXCLUDED.editing_field, + updated_at = NOW() + ` +} + +export async function dbDeleteCollaborator(huntId: number, walletAddress: string): Promise { + const sql = getDb() + await sql` + DELETE FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${walletAddress} + ` +} + +export async function dbPingPresence(huntId: number, walletAddress: string, editingField?: string | null): Promise { + const sql = getDb() + const nowMs = Date.now() + await sql` + INSERT INTO collaborator_presence (hunt_id, wallet_address, editing_field, last_ping_at) + VALUES (${huntId}, ${walletAddress}, ${editingField ?? null}, NOW()) + ON CONFLICT (hunt_id, wallet_address) DO UPDATE SET + editing_field = EXCLUDED.editing_field, + last_ping_at = EXCLUDED.last_ping_at, + updated_at = NOW() + ` + + await sql` + UPDATE hunt_collaborators + SET last_active_at = ${nowMs}, editing_field = ${editingField ?? null}, updated_at = NOW() + WHERE hunt_id = ${huntId} AND wallet_address = ${walletAddress} + ` +} + +export async function dbGetActiveEditors(huntId: number, excludeAddress?: string, staleMs = 30_000): Promise { + const sql = getDb() + const nowMs = Date.now() + const staleThresholdMs = nowMs - staleMs + const rows = await sql` + SELECT c.wallet_address, c.role, c.invited_at, c.invited_by, c.accepted, c.last_active_at, c.editing_field + FROM hunt_collaborators c + JOIN collaborator_presence p ON p.hunt_id = c.hunt_id AND p.wallet_address = c.wallet_address + WHERE c.hunt_id = ${huntId} + AND c.accepted = TRUE + AND (${excludeAddress} IS NULL OR c.wallet_address <> ${excludeAddress}) + AND c.last_active_at IS NOT NULL + AND c.last_active_at > ${staleThresholdMs} + AND c.editing_field IS NOT NULL + ORDER BY p.last_ping_at DESC + ` + return rows.map((r) => ({ + walletAddress: r.wallet_address, + role: toRole(r.role), + invitedAt: r.invited_at, + invitedBy: r.invited_by, + accepted: r.accepted, + lastActiveAt: r.last_active_at ?? undefined, + editingField: r.editing_field ?? null, + })) +} + +export async function dbGetRoleForWallet(huntId: number, walletAddress: string): Promise { + const sql = getDb() + const row = await sql` + SELECT role FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${walletAddress} + LIMIT 1 + ` + return row[0] ? toRole(row[0].role) : undefined +} + +export async function dbEnsureOwner(huntId: number, ownerAddress: string): Promise { + const sql = getDb() + const existing = await sql` + SELECT wallet_address, role, invited_at, invited_by, accepted, last_active_at, editing_field + FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${ownerAddress} + LIMIT 1 + ` + + if (existing[0]) { + if (existing[0].role !== "owner") { + await sql` + UPDATE hunt_collaborators + SET role = 'owner', accepted = TRUE, updated_at = NOW() + WHERE hunt_id = ${huntId} AND wallet_address = ${ownerAddress} + ` + return { + walletAddress: ownerAddress, + role: "owner", + invitedAt: existing[0].invited_at, + invitedBy: existing[0].invited_by, + accepted: true, + lastActiveAt: existing[0].last_active_at ?? undefined, + editingField: existing[0].editing_field ?? null, + } + } + return { + walletAddress: ownerAddress, + role: "owner", + invitedAt: existing[0].invited_at, + invitedBy: existing[0].invited_by, + accepted: existing[0].accepted, + lastActiveAt: existing[0].last_active_at ?? undefined, + editingField: existing[0].editing_field ?? null, + } + } + + const now = Math.floor(Date.now() / 1000) + await sql` + INSERT INTO hunt_collaborators (hunt_id, wallet_address, role, invited_at, invited_by, accepted, last_active_at) + VALUES (${huntId}, ${ownerAddress}, 'owner', ${now}, ${ownerAddress}, TRUE, ${Date.now()}) + ` + return { + walletAddress: ownerAddress, + role: "owner", + invitedAt: now, + invitedBy: ownerAddress, + accepted: true, + lastActiveAt: Date.now(), + editingField: null, + } +} + +export async function dbInviteCollaborator( + huntId: number, + inviterAddress: string, + walletAddress: string, + role: "editor" | "viewer" = "editor" +): Promise<{ ok: true; collaborator: HuntCollaborator } | { ok: false; error: string }> { + const sql = getDb() + const inviter = await sql` + SELECT role FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${inviterAddress} + LIMIT 1 + ` + + if (!inviter[0] || (inviter[0].role !== "owner" && inviter[0].role !== "editor")) { + const count = await sql`SELECT COUNT(*) as cnt FROM hunt_collaborators WHERE hunt_id = ${huntId}` + if (Number(count[0].cnt) === 0) { + await dbEnsureOwner(huntId, inviterAddress) + } else { + return { ok: false, error: "Only the owner can invite collaborators" } + } + } + + const address = walletAddress.trim() + if (!address.startsWith("G") || address.length !== 56) { + return { ok: false, error: "Invalid Stellar wallet address" } + } + if (address === inviterAddress) { + return { ok: false, error: "Cannot invite yourself" } + } + + const existing = await sql` + SELECT wallet_address FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${address} + LIMIT 1 + ` + if (existing[0]) { + return { ok: false, error: "Wallet is already a collaborator" } + } + + const now = Math.floor(Date.now() / 1000) + await sql` + INSERT INTO hunt_collaborators (hunt_id, wallet_address, role, invited_at, invited_by, accepted) + VALUES (${huntId}, ${address}, ${role}, ${now}, ${inviterAddress}, FALSE) + ` + + const collaborator: HuntCollaborator = { + walletAddress: address, + role, + invitedAt: now, + invitedBy: inviterAddress, + accepted: false, + editingField: null, + } + return { ok: true, collaborator } +} + +export async function dbAcceptInvite(huntId: number, walletAddress: string): Promise { + const sql = getDb() + const result = await sql` + UPDATE hunt_collaborators + SET accepted = TRUE, last_active_at = ${Date.now()}, updated_at = NOW() + WHERE hunt_id = ${huntId} AND wallet_address = ${walletAddress} + RETURNING wallet_address + ` + return result.length > 0 +} + +export async function dbUpdateCollaboratorRole( + huntId: number, + actorAddress: string, + targetAddress: string, + role: "editor" | "viewer" +): Promise<{ ok: true; collaborator: HuntCollaborator } | { ok: false; error: string }> { + const sql = getDb() + const actor = await sql` + SELECT role FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${actorAddress} + LIMIT 1 + ` + if (!actor[0] || actor[0].role !== "owner") { + return { ok: false, error: "Only the owner can change roles" } + } + + const target = await sql` + SELECT wallet_address, role FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${targetAddress} + LIMIT 1 + ` + if (!target[0]) { + return { ok: false, error: "Collaborator not found" } + } + if (target[0].role === "owner") { + return { ok: false, error: "Cannot demote the owner; transfer ownership instead" } + } + + await sql` + UPDATE hunt_collaborators + SET role = ${role}, updated_at = NOW() + WHERE hunt_id = ${huntId} AND wallet_address = ${targetAddress} + ` + + return { + ok: true, + collaborator: { + walletAddress: targetAddress, + role, + invitedAt: 0, + invitedBy: actorAddress, + accepted: true, + editingField: null, + }, + } +} + +export async function dbRemoveCollaborator( + huntId: number, + actorAddress: string, + targetAddress: string +): Promise<{ ok: true } | { ok: false; error: string }> { + const sql = getDb() + const actor = await sql` + SELECT role FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${actorAddress} + LIMIT 1 + ` + + const isSelf = actorAddress === targetAddress + if (!actor[0] || (actor[0].role !== "owner" && !isSelf)) { + return { ok: false, error: "Not allowed to remove this collaborator" } + } + + const target = await sql` + SELECT role FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${targetAddress} + LIMIT 1 + ` + if (!target[0]) { + return { ok: false, error: "Collaborator not found" } + } + if (target[0].role === "owner") { + return { ok: false, error: "Cannot remove the owner" } + } + + await sql` + DELETE FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${targetAddress} + ` + await sql` + DELETE FROM collaborator_presence + WHERE hunt_id = ${huntId} AND wallet_address = ${targetAddress} + ` + return { ok: true } +} + +export async function dbTransferOwnership( + huntId: number, + currentOwner: string, + newOwner: string +): Promise<{ ok: true } | { ok: false; error: string }> { + const sql = getDb() + const owner = await sql` + SELECT role FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${currentOwner} + LIMIT 1 + ` + if (!owner[0] || owner[0].role !== "owner") { + return { ok: false, error: "Only the current owner can transfer ownership" } + } + + const next = await sql` + SELECT role, accepted FROM hunt_collaborators + WHERE hunt_id = ${huntId} AND wallet_address = ${newOwner} + LIMIT 1 + ` + if (!next[0]) { + return { ok: false, error: "New owner must already be a collaborator" } + } + if (!next[0].accepted) { + return { ok: false, error: "New owner must accept their invite first" } + } + + await sql` + UPDATE hunt_collaborators + SET role = 'owner', updated_at = NOW() + WHERE hunt_id = ${huntId} AND wallet_address = ${newOwner} + ` + await sql` + UPDATE hunt_collaborators + SET role = 'editor', updated_at = NOW() + WHERE hunt_id = ${huntId} AND wallet_address = ${currentOwner} + ` + + return { ok: true } +} diff --git a/apps/web/lib/csv.ts b/apps/web/lib/csv.ts new file mode 100644 index 000000000..d35d8062d --- /dev/null +++ b/apps/web/lib/csv.ts @@ -0,0 +1,104 @@ +/** + * Minimal CSV parser for clue imports. + * + * Supports: + * - Comma-separated values + * - Optional header row + * - Quoted fields containing commas, newlines, or escaped quotes + */ + +export interface CsvRow { + question: string + answer: string + points: number + hint?: string + hintCost?: number + difficulty?: string +} + +export interface CsvParseResult { + rows: CsvRow[] + errors: { row: number; message: string }[] +} + +export function parseClueCsv(text: string): CsvParseResult { + const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0) + if (lines.length === 0) { + return { rows: [], errors: [] } + } + + const firstLine = lines[0] + const hasHeader = /question\s*[,|]|answer\s*[,|]|points\s*[,|]/i.test(firstLine) + const startIndex = hasHeader ? 1 : 0 + const rows: CsvRow[] = [] + const errors: { row: number; message: string }[] = [] + + for (let i = startIndex; i < lines.length; i++) { + const rowNumber = i + 1 + const fields = splitCsvLine(lines[i]) + if (fields.length < 2) { + errors.push({ row: rowNumber, message: "Row must have at least question and answer" }) + continue + } + + const [rawQuestion, rawAnswer, rawPoints, rawHint, rawHintCost, rawDifficulty] = fields + + if (!rawQuestion.trim()) { + errors.push({ row: rowNumber, message: "Question is required" }) + } + if (!rawAnswer.trim()) { + errors.push({ row: rowNumber, message: "Answer is required" }) + } + + const points = rawPoints ? parseInt(rawPoints, 10) : NaN + if (!rawPoints.trim() || Number.isNaN(points) || points < 1) { + errors.push({ row: rowNumber, message: "Points must be a positive integer" }) + } + + const hintCost = rawHintCost ? parseInt(rawHintCost, 10) : undefined + if (rawHintCost && (!Number.isInteger(hintCost) || hintCost < 0)) { + errors.push({ row: rowNumber, message: "Hint cost must be a non-negative integer" }) + } + + const difficulty = rawDifficulty?.trim() + if (difficulty && !["Easy", "Medium", "Hard"].includes(difficulty)) { + errors.push({ row: rowNumber, message: "Difficulty must be Easy, Medium, or Hard" }) + } + + rows.push({ + question: rawQuestion.trim(), + answer: rawAnswer.trim(), + points: Number.isNaN(points) ? 10 : points, + hint: rawHint?.trim() || undefined, + hintCost: Number.isNaN(hintCost!) ? 0 : (hintCost ?? 0), + difficulty: difficulty as CsvRow["difficulty"], + }) + } + + return { rows, errors } +} + +function splitCsvLine(line: string): string[] { + const fields: string[] = [] + let current = "" + let inQuotes = false + + for (let i = 0; i < line.length; i++) { + const char = line[i] + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + current += '"' + i++ + } else { + inQuotes = !inQuotes + } + } else if (char === "," && !inQuotes) { + fields.push(current) + current = "" + } else { + current += char + } + } + fields.push(current) + return fields +} diff --git a/apps/web/lib/db/migrations/010_create_collaboration_tables.sql b/apps/web/lib/db/migrations/010_create_collaboration_tables.sql new file mode 100644 index 000000000..de0e9a073 --- /dev/null +++ b/apps/web/lib/db/migrations/010_create_collaboration_tables.sql @@ -0,0 +1,40 @@ +-- Migration: create hunt_collaborators and collaborator_presence tables. +-- +-- Moves collaboration state from ephemeral localStorage/memory stores to +-- durable PostgreSQL so that presence and permissions are consistent across +-- all server instances and clients. + +CREATE TABLE IF NOT EXISTS hunt_collaborators ( + id BIGSERIAL PRIMARY KEY, + hunt_id INTEGER NOT NULL, + wallet_address TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer')), + invited_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()), + invited_by TEXT NOT NULL, + accepted BOOLEAN NOT NULL DEFAULT FALSE, + last_active_at BIGINT, + editing_field TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_hunt_collaborator UNIQUE (hunt_id, wallet_address) +); + +CREATE INDEX IF NOT EXISTS idx_hunt_collaborators_hunt_id + ON hunt_collaborators (hunt_id); + +CREATE TABLE IF NOT EXISTS collaborator_presence ( + id BIGSERIAL PRIMARY KEY, + hunt_id INTEGER NOT NULL, + wallet_address TEXT NOT NULL, + editing_field TEXT, + last_ping_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_hunt_presence UNIQUE (hunt_id, wallet_address) +); + +CREATE INDEX IF NOT EXISTS idx_collaborator_presence_hunt_id + ON collaborator_presence (hunt_id); + +CREATE INDEX IF NOT EXISTS idx_collaborator_presence_last_ping + ON collaborator_presence (last_ping_at DESC); diff --git a/apps/web/tests/api_route_contract.test.ts b/apps/web/tests/api_route_contract.test.ts index 6e4326415..1c0336e4c 100644 --- a/apps/web/tests/api_route_contract.test.ts +++ b/apps/web/tests/api_route_contract.test.ts @@ -211,6 +211,8 @@ const ROUTE_MANIFEST: RouteEntry[] = [ { file: "v1/hunts/[id]/route.ts", path: "/api/v1/hunts/[id]", methods: ["GET"], auth: "public" }, { file: "v1/hunts/[id]/archive/route.ts", path: "/api/v1/hunts/[id]/archive", methods: ["POST"], auth: "public" }, { file: "v1/hunts/[id]/collaborators/route.ts", path: "/api/v1/hunts/[id]/collaborators", methods: ["GET", "POST"], auth: "public" }, + { file: "v1/hunts/[id]/collaborators/presence/route.ts", path: "/api/v1/hunts/[id]/collaborators/presence", methods: ["GET", "POST"], auth: "public" }, + { file: "v1/hunts/[id]/collaborators/presence/stream/route.ts", path: "/api/v1/hunts/[id]/collaborators/presence/stream", methods: ["GET"], auth: "public" }, { file: "v1/hunts/[id]/complete/route.ts", path: "/api/v1/hunts/[id]/complete", methods: ["POST"], auth: "public" }, { file: "v1/hunts/[id]/delete/route.ts", path: "/api/v1/hunts/[id]/delete", methods: ["POST"], auth: "public" }, { file: "v1/hunts/[id]/leaderboard/route.ts", path: "/api/v1/hunts/[id]/leaderboard", methods: ["GET"], auth: "public" }, diff --git a/packages/types/src/api-schemas.ts b/packages/types/src/api-schemas.ts index 546dfd71c..0313e0f19 100644 --- a/packages/types/src/api-schemas.ts +++ b/packages/types/src/api-schemas.ts @@ -258,6 +258,16 @@ export const collaboratorsBodySchema = z.discriminatedUnion("action", [ }), ]) +export const presencePingBodySchema = z.object({ + walletAddress: nonEmptyStringSchema, + editingField: z.string().optional().nullable(), +}) + +export const presenceQuerySchema = z.object({ + walletAddress: nonEmptyStringSchema.optional(), + staleMs: z.number().int().positive().optional().default(30000), +}) + // ─── v1 / Hunts / [id] / Progress ──────────────────────────────────────────── export const huntProgressBodySchema = z.object({ @@ -390,6 +400,8 @@ export const apiSchemas = { huntArchiveBody: huntArchiveBodySchema, huntDeleteBody: huntDeleteBodySchema, collaboratorsBody: collaboratorsBodySchema, + presencePingBody: presencePingBodySchema, + presenceQuery: presenceQuerySchema, huntProgressBody: huntProgressBodySchema, huntProgressQuery: huntProgressQuerySchema, huntCompleteBody: huntCompleteBodySchema,