spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1/** Private cookie-scoped watchlist (no accounts): GET / POST / DELETE /watchlist, GET /watchlist/feed. Never cached. */2import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";3import { z } from "zod";4import { getEnv } from "../../env.js";5import { envelope, HttpError, parseBody, parseQuery } from "../../lib/http.js";6import { intParam, pageParam } from "../../lib/params.js";7import { registerRoute } from "../../lib/route.js";8import { addWatch, listWatchlist, newWatchToken, parseCookies, removeWatch, resolveWatchEntity, watchFeed, WATCH_COOKIE, WATCH_ENTITY_TYPES, WATCH_MAX_ITEMS } from "../../repositories/watchlist.js";910const 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");1112/** Token from the cookie, minting (and setting) a new one when absent. */13function tokenFor(req: FastifyRequest, reply: FastifyReply, create: boolean): string | null {14 const existing = parseCookies(req.headers.cookie)[WATCH_COOKIE];15 if (existing) return existing;16 if (!create) return null;17 const token = newWatchToken();18 const secure = getEnv().siteUrl.startsWith("https://") ? "; Secure" : "";19 reply.header("set-cookie", `${WATCH_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${secure}`);20 return token;21}2223const NO_STORE = { "cache-control": "no-store", vary: "cookie" };2425export async function watchlistRoutes(app: FastifyInstance): Promise<void> {26 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[]" });27 app.get("/watchlist", { schema: { summary: "Private watchlist (WatchlistItem[]) keyed by the dci_watch cookie", tags: ["watchlist"] } }, async (req, reply) => {28 reply.headers(NO_STORE);29 const token = tokenFor(req, reply, true)!;30 const items = await listWatchlist(token);31 return envelope(items, { total: items.length, max: WATCH_MAX_ITEMS });32 });3334 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" });35 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) => {36 reply.headers(NO_STORE);37 const body = parseBody(addBody, req.body);38 const id = await resolveWatchEntity(body.entityType, body.entityId ?? body.slug!);39 if (!id) throw new HttpError(404, `${body.entityType} not found`);40 const token = tokenFor(req, reply, true)!;41 const res = await addWatch(token, body.entityType, id);42 if ("error" in res) throw new HttpError(409, `watchlist is full (${WATCH_MAX_ITEMS} items)`);43 reply.code(res.created ? 201 : 200);44 return envelope(res.item, { created: res.created });45 });4647 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 }" });48 app.delete("/watchlist/:id", { schema: { summary: "Remove a watchlist item (owner only)", tags: ["watchlist"], params: { type: "object", properties: { id: { type: "string" } } } } }, async (req, reply) => {49 reply.headers(NO_STORE);50 const token = tokenFor(req, reply, false);51 const { id } = req.params as { id: string };52 const deleted = token ? await removeWatch(token, id) : false;53 return envelope({ id, deleted });54 });5556 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[]" });57 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) => {58 reply.headers(NO_STORE);59 const q = parseQuery(z.object({ page: pageParam, per_page: intParam }), req.query);60 const token = tokenFor(req, reply, false);61 if (!token) return envelope([], { total: 0, page: q.page, perPage: q.per_page ?? 50 });62 const res = await watchFeed(token, q.page, q.per_page);63 return envelope(res.items, { total: res.total, page: res.page, perPage: res.perPage });64 });65}66