Skip to content
File

Blob: src/worker/routes/page-mentions.ts

typescript72 lines
1import { Hono } from "hono";
2import { eq, and, isNull, inArray } from "drizzle-orm";
3 
4import type { AppContext } from "@/worker/app-context";
5import { pages, workspaces } from "@/worker/db/d1/schema";
6import { optionalAuth } from "@/worker/middleware/auth";
7import { rateLimit } from "@/worker/middleware/rate-limit";
8import { resolvePageAccessLevels, resolvePrincipal } from "@/worker/lib/permissions";
9import { parseBody } from "@/worker/lib/validate";
10import { ResolvePageMentionsRequest, type ResolvedPageMentionItem } from "@/shared/types";
11 
12const 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.
18pageMentionsRouter.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 
71export { pageMentionsRouter };