/** Private cookie-scoped watchlist (no accounts): GET / POST / DELETE /watchlist, GET /watchlist/feed. Never cached. */ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { getEnv } from "../../env.js"; import { envelope, HttpError, parseBody, parseQuery } from "../../lib/http.js"; import { intParam, pageParam } from "../../lib/params.js"; import { registerRoute } from "../../lib/route.js"; import { addWatch, listWatchlist, newWatchToken, parseCookies, removeWatch, resolveWatchEntity, watchFeed, WATCH_COOKIE, WATCH_ENTITY_TYPES, WATCH_MAX_ITEMS } from "../../repositories/watchlist.js"; const addBody = z.object({ entityType: z.enum(WATCH_ENTITY_TYPES), entityId: z.string().min(1).max(120).optional(), slug: z.string().min(1).max(200).optional() }).strict().refine((b) => b.entityId || b.slug, "entityId or slug is required"); /** Token from the cookie, minting (and setting) a new one when absent. */ function tokenFor(req: FastifyRequest, reply: FastifyReply, create: boolean): string | null { const existing = parseCookies(req.headers.cookie)[WATCH_COOKIE]; if (existing) return existing; if (!create) return null; const token = newWatchToken(); const secure = getEnv().siteUrl.startsWith("https://") ? "; Secure" : ""; reply.header("set-cookie", `${WATCH_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${secure}`); return token; } const NO_STORE = { "cache-control": "no-store", vary: "cookie" }; export async function watchlistRoutes(app: FastifyInstance): Promise { registerRoute({ method: "GET", path: "/api/v1/watchlist", summary: "Your private watchlist (WatchlistItem[]) — keyed by the httpOnly dci_watch cookie, created on first use; no accounts", group: "watchlist", params: [], responseType: "WatchlistItem[]" }); app.get("/watchlist", { schema: { summary: "Private watchlist (WatchlistItem[]) keyed by the dci_watch cookie", tags: ["watchlist"] } }, async (req, reply) => { reply.headers(NO_STORE); const token = tokenFor(req, reply, true)!; const items = await listWatchlist(token); return envelope(items, { total: items.length, max: WATCH_MAX_ITEMS }); }); registerRoute({ method: "POST", path: "/api/v1/watchlist", summary: "Watch an entity: body { entityType: operator|metro|country|project|facility, entityId | slug } → WatchlistItem (201 when created)", group: "watchlist", params: [{ name: "entityType", in: "query", type: "enum", description: WATCH_ENTITY_TYPES.join(" | "), example: "operator" }, { name: "slug", in: "query", type: "string", description: "entity slug (or entityId)", example: "equinix" }], responseType: "WatchlistItem" }); app.post("/watchlist", { schema: { summary: "Add an entity to the private watchlist { entityType, entityId | slug }", tags: ["watchlist"], body: { type: "object", properties: { entityType: { type: "string", enum: [...WATCH_ENTITY_TYPES] }, entityId: { type: "string" }, slug: { type: "string" } }, required: ["entityType"] } } }, async (req, reply) => { reply.headers(NO_STORE); const body = parseBody(addBody, req.body); const id = await resolveWatchEntity(body.entityType, body.entityId ?? body.slug!); if (!id) throw new HttpError(404, `${body.entityType} not found`); const token = tokenFor(req, reply, true)!; const res = await addWatch(token, body.entityType, id); if ("error" in res) throw new HttpError(409, `watchlist is full (${WATCH_MAX_ITEMS} items)`); reply.code(res.created ? 201 : 200); return envelope(res.item, { created: res.created }); }); registerRoute({ method: "DELETE", path: "/api/v1/watchlist/:id", summary: "Stop watching (only your own rows) → { deleted }", group: "watchlist", params: [{ name: "id", in: "path", type: "string", description: "watchlist item id (wtc_…)" }], responseType: "{ deleted: boolean }" }); app.delete("/watchlist/:id", { schema: { summary: "Remove a watchlist item (owner only)", tags: ["watchlist"], params: { type: "object", properties: { id: { type: "string" } } } } }, async (req, reply) => { reply.headers(NO_STORE); const token = tokenFor(req, reply, false); const { id } = req.params as { id: string }; const deleted = token ? await removeWatch(token, id) : false; return envelope({ id, deleted }); }); registerRoute({ method: "GET", path: "/api/v1/watchlist/feed", summary: "Events for your watched entities (EventDTO[]), newest first, one row per announcement cluster", group: "watchlist", params: [{ name: "page", in: "query", type: "integer", description: "page (default 1)" }, { name: "per_page", in: "query", type: "integer", description: "rows per page (default 50, max 100)" }], responseType: "EventDTO[]" }); app.get("/watchlist/feed", { schema: { summary: "Change feed for the watched entities (EventDTO[])", tags: ["watchlist"], querystring: { type: "object", properties: { page: { type: "integer" }, per_page: { type: "integer" } } } } }, async (req, reply) => { reply.headers(NO_STORE); const q = parseQuery(z.object({ page: pageParam, per_page: intParam }), req.query); const token = tokenFor(req, reply, false); if (!token) return envelope([], { total: 0, page: q.page, perPage: q.per_page ?? 50 }); const res = await watchFeed(token, q.page, q.per_page); return envelope(res.items, { total: res.total, page: res.page, perPage: res.perPage }); }); }