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
67 changes: 67 additions & 0 deletions apps/web/app/api/v1/hunts/[id]/collaborators/presence/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
)
Original file line number Diff line number Diff line change
@@ -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",
},
})
})
29 changes: 22 additions & 7 deletions apps/web/app/api/v1/hunts/[id]/collaborators/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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),
})
})
Expand All @@ -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 })
}
Expand Down
Loading