File
Blob: src/worker/routes/page-mentions.ts
| 1 | import { Hono } from "hono"; |
| 2 | import { eq, and, isNull, inArray } from "drizzle-orm"; |
| 3 | |
| 4 | import type { AppContext } from "@/worker/app-context"; |
| 5 | import { pages, workspaces } from "@/worker/db/d1/schema"; |
| 6 | import { optionalAuth } from "@/worker/middleware/auth"; |
| 7 | import { rateLimit } from "@/worker/middleware/rate-limit"; |
| 8 | import { resolvePageAccessLevels, resolvePrincipal } from "@/worker/lib/permissions"; |
| 9 | import { parseBody } from "@/worker/lib/validate"; |
| 10 | import { ResolvePageMentionsRequest, type ResolvedPageMentionItem } from "@/shared/types"; |
| 11 | |
| 12 | const pageMentionsRouter = new Hono<AppContext>(); |
| 13 | |
| 14 | // POST /workspaces/:wid/page-mentions/resolve |
| 15 | // Resolves a batch of pageIds to mention metadata for the current viewer. |
| 16 | // Missing, archived, cross-workspace, and inaccessible ids all collapse to |
| 17 | // { accessible: false, title: null, icon: null } so the client cannot distinguish. |
| 18 | pageMentionsRouter.post("/workspaces/:wid/page-mentions/resolve", optionalAuth, rateLimit("RL_API"), async (c) => { |
| 19 | const workspaceId = c.req.param("wid"); |
| 20 | const user = c.get("user"); |
| 21 | const db = c.get("db"); |
| 22 | const shareToken = c.req.query("share"); |
| 23 | |
| 24 | const data = await parseBody(c, ResolvePageMentionsRequest); |
| 25 | if (data instanceof Response) return data; |
| 26 | |
| 27 | const resolved = await resolvePrincipal(db, user, workspaceId, { |
| 28 | surface: shareToken ? "shared" : "canonical", |
| 29 | shareToken, |
| 30 | }); |
| 31 | if (!resolved) { |
| 32 | return c.json({ error: "unauthorized", message: "Authentication required" }, 401); |
| 33 | } |
| 34 | |
| 35 | const workspaceExists = await db |
| 36 | .select({ id: workspaces.id }) |
| 37 | .from(workspaces) |
| 38 | .where(eq(workspaces.id, workspaceId)) |
| 39 | .get(); |
| 40 | if (!workspaceExists) { |
| 41 | return c.json({ error: "not_found", message: "Workspace not found" }, 404); |
| 42 | } |
| 43 | |
| 44 | const requestedIds = [...new Set(data.page_ids)]; |
| 45 | const accessLevels = await resolvePageAccessLevels(db, resolved.principal, requestedIds, workspaceId); |
| 46 | |
| 47 | const accessibleIds = requestedIds.filter((id) => (accessLevels.get(id) ?? "none") !== "none"); |
| 48 | |
| 49 | const metaById = new Map<string, { title: string; icon: string | null }>(); |
| 50 | if (accessibleIds.length > 0) { |
| 51 | const rows = await db |
| 52 | .select({ id: pages.id, title: pages.title, icon: pages.icon }) |
| 53 | .from(pages) |
| 54 | .where(and(inArray(pages.id, accessibleIds), eq(pages.workspace_id, workspaceId), isNull(pages.archived_at))); |
| 55 | for (const row of rows) { |
| 56 | metaById.set(row.id, { title: row.title, icon: row.icon }); |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | const mentions: ResolvedPageMentionItem[] = requestedIds.map((pageId) => { |
| 61 | const meta = metaById.get(pageId); |
| 62 | if (!meta) { |
| 63 | return { page_id: pageId, accessible: false, title: null, icon: null }; |
| 64 | } |
| 65 | return { page_id: pageId, accessible: true, title: meta.title, icon: meta.icon }; |
| 66 | }); |
| 67 | |
| 68 | return c.json({ mentions }); |
| 69 | }); |
| 70 | |
| 71 | export { pageMentionsRouter }; |