diff --git a/packages/api/src/routes/projects.test.ts b/packages/api/src/routes/projects.test.ts index df75197..4ab4446 100644 --- a/packages/api/src/routes/projects.test.ts +++ b/packages/api/src/routes/projects.test.ts @@ -153,6 +153,39 @@ describe("handleProjects GET list / POST create", () => { expect(json.data).toHaveLength(1); }); + it("only computes doc/member counts when asked with ?counts=1", async () => { + // Real SQLite so the conditional SQL actually runs against the schema. + const { db, sqlite } = createSqliteD1(); + const T0 = "2026-01-01T00:00:00.000Z"; + const exec = (sql: string, ...args: (string | number)[]) => sqlite.prepare(sql).run(...args); + exec("INSERT INTO projects (id, name, owner_id, created_at) VALUES ('p1', 'Site', 'user-1', ?)", T0); + exec("INSERT INTO projects (id, name, owner_id, created_at) VALUES ('p2', 'Not mine', 'user-2', ?)", T0); + const member = (id: string, projectId: string, userId: string, role: string, accepted: number) => + exec("INSERT INTO project_members (id, project_id, user_id, email, name, role, invited_by, created_at, accepted) VALUES (?, ?, ?, 'e', 'n', ?, 'user-1', ?, ?)", + id, projectId, userId, role, T0, accepted); + member("m1", "p1", "user-1", "owner", 1); + member("m2", "p1", "user-3", "viewer", 1); + member("m3", "p1", "user-4", "viewer", 0); // pending - not counted + member("m4", "p2", "user-2", "owner", 1); + for (const id of ["d1", "d2"]) { + exec("INSERT INTO docs (id, title, project_id, author_id, created_at, updated_at) VALUES (?, 'D', 'p1', 'user-1', ?, ?)", id, T0, T0); + } + const { env: mockEnv } = makeEnv(); + const env = { ...mockEnv, DB: db } as typeof mockEnv; + + const plain = await call(env, "GET", "/projects"); + const plainRows = ((await plain.json()) as { data: Record[] }).data; + expect(plainRows.map(r => r.id)).toEqual(["p1"]); + expect(plainRows[0]).toMatchObject({ id: "p1", name: "Site", role: "owner" }); + expect(plainRows[0]).not.toHaveProperty("doc_count"); + expect(plainRows[0]).not.toHaveProperty("member_count"); + + const counted = await call(env, "GET", "/projects?counts=1"); + const countedRows = ((await counted.json()) as { data: Record[] }).data; + expect(countedRows).toHaveLength(1); + expect(countedRows[0]).toMatchObject({ id: "p1", role: "owner", doc_count: 2, member_count: 2 }); + }); + it("400s POST without a name", async () => { const { env } = makeEnv(); const res = await call(env, "POST", "/projects", { description: "x" }); diff --git a/packages/api/src/routes/projects.ts b/packages/api/src/routes/projects.ts index a331e22..5cf110d 100644 --- a/packages/api/src/routes/projects.ts +++ b/packages/api/src/routes/projects.ts @@ -83,21 +83,28 @@ export async function handleProjects( // JSON or the logo_wide / vanity_slug / home_doc_id columns that the list // consumers (DashboardPage, DocsLayout, UserSettingsPage) never read - those // belong on the single-project endpoint. + // The per-site doc_count / member_count subqueries are opt-in (`?counts=1`): + // only the dashboard cards render them, and they're two correlated COUNTs + // per site on a list the sidebar loads on every app boot. if (!projectId && request.method === "GET") { + const withCounts = url.searchParams.get("counts") === "1"; + const countColumns = withCounts + ? `, + (SELECT COUNT(*) FROM docs WHERE project_id = p.id) as doc_count, + (SELECT COUNT(*) FROM project_members WHERE project_id = p.id AND accepted = 1) as member_count` + : ""; const rows = await env.DB.prepare( `SELECT p.id, p.name, p.description, p.owner_id, p.created_at, p.published_at, p.changelog_mode, p.ai_enabled, p.ai_summarization_type, p.graph_enabled, p.features, p.organization_id, p.logo_square_updated_at, pm.role, pm.is_favourite, pm.is_hidden, - o.name AS organization_name, - (SELECT COUNT(*) FROM docs WHERE project_id = p.id) as doc_count, - (SELECT COUNT(*) FROM project_members WHERE project_id = p.id AND accepted = 1) as member_count + o.name AS organization_name${countColumns} FROM projects p INNER JOIN project_members pm ON pm.project_id = p.id LEFT JOIN organizations o ON o.id = p.organization_id WHERE pm.user_id = ? AND pm.accepted = 1 ORDER BY pm.is_favourite DESC, p.created_at DESC`, - ).bind(user.userId).all(); + ).bind(user.userId).all(); return okResponse(rows.results); } diff --git a/packages/api/src/routes/public.test.ts b/packages/api/src/routes/public.test.ts index 87c5ec1..7dcec57 100644 --- a/packages/api/src/routes/public.test.ts +++ b/packages/api/src/routes/public.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("../auth", () => ({ authenticate: vi.fn() })); @@ -390,6 +390,7 @@ describe("POST /public/projects/:idOrSlug/report", () => { expect(ip).toBe("203.0.113.9"); expect(docId).toBeNull(); expect(note).toBe("This site hosts spam."); + expect(calls[1].binds[6]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); // No Authorization header -> authenticate is never consulted. expect(authenticate).not.toHaveBeenCalled(); }); @@ -466,4 +467,201 @@ describe("POST /public/projects/:idOrSlug/report", () => { const res = await handlePublicReport(request, env, url); expect(res.status).toBe(201); }); + + it("stores created_at as an ISO timestamp, not the zone-less column default", async () => { + // Real SQLite: the column default would write "YYYY-MM-DD HH:MM:SS". + const { db, sqlite } = createSqliteD1(); + sqlite.prepare("INSERT INTO projects (id, name, owner_id, created_at, published_at) VALUES ('p1', 'S', 'u1', ?, ?)") + .run("2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"); + const env = { DB: db } as unknown as Env; + const { request, url } = reportRequest({ note: "spam" }); + const res = await handlePublicReport(request, env, url); + expect(res.status).toBe(201); + const row = sqlite.prepare("SELECT created_at FROM site_reports").get() as { created_at: string }; + expect(row.created_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); +}); + +// --------------------------------------------------------------------------- +// GET /public/docs/:site/:idOrSlug and GET /public/projects/:idOrSlug - +// real SQLite, so the batched statements (and the published_at filters baked +// into them) are actually evaluated. +// --------------------------------------------------------------------------- + +describe("public site reads (batched)", () => { + const T0 = "2026-01-01T00:00:00.000Z"; + + function setup() { + const { db, sqlite } = createSqliteD1(); + const exec = (sql: string, ...args: (string | number | null)[]) => sqlite.prepare(sql).run(...args); + const project = (id: string, opts: { published?: boolean; slug?: string; home?: string } = {}) => + exec("INSERT INTO projects (id, name, owner_id, created_at, published_at, vanity_slug, home_doc_id) VALUES (?, ?, 'u1', ?, ?, ?, ?)", + id, `Site ${id}`, T0, opts.published ? T0 : null, opts.slug ?? null, opts.home ?? null); + const doc = (id: string, projectId: string, opts: { title?: string; slug?: string; published?: boolean; position?: number } = {}) => + exec("INSERT INTO docs (id, title, project_id, author_id, created_at, updated_at, published_at, slug, sidebar_position) VALUES (?, ?, ?, 'u1', ?, ?, ?, ?, ?)", + id, opts.title ?? id, projectId, T0, T0, opts.published ? T0 : null, opts.slug ?? null, opts.position ?? null); + const folder = (id: string, projectId: string) => + exec("INSERT INTO folders (id, name, project_id, created_at) VALUES (?, ?, ?, ?)", id, `Folder ${id}`, projectId, T0); + const file = (id: string, projectId: string) => + exec("INSERT INTO files (id, name, mime_type, size, project_id, uploaded_by, created_at) VALUES (?, ?, 'image/png', 1, ?, 'u1', ?)", id, `${id}.png`, projectId, T0); + + // Published site with an active custom domain, nav, and a home doc. + project("pub", { published: true, slug: "pub-slug", home: "home" }); + exec("INSERT INTO project_custom_domains (project_id, hostname, status) VALUES ('pub', 'docs.example.com', 'active')"); + doc("home", "pub", { title: "Home", slug: "welcome" }); + doc("second", "pub", { title: "Second", position: 1 }); + folder("pub-folder", "pub"); + file("pub-file", "pub"); + + // Unpublished site: one solo-published doc, one private doc, private nav. + project("priv"); + doc("solo", "priv", { title: "Solo", slug: "solo-slug", published: true }); + doc("secret", "priv", { title: "Secret doc" }); + folder("secret-folder", "priv"); + file("secret-file", "priv"); + + const ASSETS = { get: vi.fn(async (key: string) => ({ text: async () => `---\ntitle: Display ${key}\n---\nbody of ${key}` })) }; + const batch = vi.spyOn(db, "batch"); + // Every Stmt shares one prototype: spying on it catches any read issued + // outside the batch (a regression back to sequential round trips). + const stmtProto = Object.getPrototypeOf(db.prepare("SELECT 1")) as { first: () => unknown; all: () => unknown }; + const directFirst = vi.spyOn(stmtProto, "first"); + const directAll = vi.spyOn(stmtProto, "all"); + const env = { DB: db, ASSETS } as unknown as Env; + const get = async (path: string) => { + const url = new URL(`http://localhost/public/${path}`); + const res = await handlePublic(new Request(url), env, url); + return { res, body: res.status === 200 ? await res.json<{ data: Record }>() : null }; + }; + // Row counts each batched statement returned for the last request - an + // unpublished site's nav rows must never leave the DB, not just be dropped. + const lastBatchRows = async () => { + const results = (await batch.mock.results[batch.mock.results.length - 1].value) as { results: unknown[] }[]; + return results.map(r => r.results.length); + }; + return { get, batch, directFirst, directAll, ASSETS, lastBatchRows, project, doc }; + } + + afterEach(() => vi.restoreAllMocks()); + + describe("GET /public/docs/:site/:idOrSlug", () => { + it("serves a published site's doc and nav in one D1 batch", async () => { + const { get, batch, directFirst, directAll, ASSETS } = setup(); + const { res, body } = await get("docs/pub/home"); + expect(res.status).toBe(200); + expect(batch).toHaveBeenCalledTimes(1); + expect(directFirst).not.toHaveBeenCalled(); + expect(directAll).not.toHaveBeenCalled(); + expect(ASSETS.get).toHaveBeenCalledWith("pub/home"); + + const data = body!.data as { + doc: { id: string; slug: string; display_title: string }; + sitePublished: boolean; + project: { id: string; custom_domain: string | null; home_doc_id: string }; + docs: { id: string; is_home: number }[]; + folders: { id: string }[]; + files: { id: string }[]; + }; + expect(data.sitePublished).toBe(true); + expect(data.doc).toMatchObject({ id: "home", slug: "welcome", display_title: "Display pub/home" }); + expect(data.project).toMatchObject({ id: "pub", custom_domain: "docs.example.com", home_doc_id: "home" }); + // Sidebar order (positioned first, then by title) and the home flag. + expect(data.docs.map(d => [d.id, d.is_home])).toEqual([["second", 0], ["home", 1]]); + expect(data.folders.map(f => f.id)).toEqual(["pub-folder"]); + expect(data.files.map(f => f.id)).toEqual(["pub-file"]); + }); + + it("resolves the site by vanity slug and the doc by its custom slug", async () => { + const { get, ASSETS } = setup(); + const { res, body } = await get("docs/pub-slug/welcome"); + expect(res.status).toBe(200); + expect((body!.data.doc as { id: string }).id).toBe("home"); + // R2 is keyed by the resolved ids, never by the URL segments. + expect(ASSETS.get).toHaveBeenCalledWith("pub/home"); + }); + + it("serves a solo-published doc on an unpublished site without any nav data", async () => { + const { get, lastBatchRows } = setup(); + for (const path of ["docs/priv/solo", "docs/priv/solo-slug"]) { + const { res, body } = await get(path); + expect(res.status).toBe(200); + expect(body!.data).toMatchObject({ sitePublished: false, docs: null, folders: null, files: null }); + // [project, doc, nav docs, nav folders, nav files] + expect(await lastBatchRows()).toEqual([1, 1, 0, 0, 0]); + expect(JSON.stringify(body)).not.toContain("secret"); + } + }); + + it("404s an unpublished doc on an unpublished site, without reading R2", async () => { + const { get, ASSETS, lastBatchRows } = setup(); + const { res } = await get("docs/priv/secret"); + expect(res.status).toBe(404); + expect(ASSETS.get).not.toHaveBeenCalled(); + expect(await lastBatchRows()).toEqual([1, 1, 0, 0, 0]); + }); + + it("404s an unknown site, an unknown doc, and another site's doc", async () => { + const { get, ASSETS } = setup(); + expect((await get("docs/nope/home")).res.status).toBe(404); + expect((await get("docs/pub/nope")).res.status).toBe(404); + // A doc id from a different site never resolves under this one. + expect((await get("docs/pub/solo")).res.status).toBe(404); + expect(ASSETS.get).not.toHaveBeenCalled(); + }); + + it("prefers an exact site id over another site's identical vanity slug, in every statement", async () => { + const { get, project, doc } = setup(); + // Vanity slugs may be uuid-shaped, so an id and a slug can collide. + project("shared", { published: true }); + doc("shared-doc", "shared", { title: "Shared", slug: "intro" }); + project("other", { published: true, slug: "shared" }); + doc("other-doc", "other", { title: "Other", slug: "intro" }); + const { res, body } = await get("docs/shared/intro"); + expect(res.status).toBe(200); + const data = body!.data as { doc: { id: string }; project: { id: string }; docs: { id: string }[] }; + expect(data.doc.id).toBe("shared-doc"); + expect(data.project.id).toBe("shared"); + expect(data.docs.map(d => d.id)).toEqual(["shared-doc"]); + }); + }); + + describe("GET /public/projects/:idOrSlug", () => { + it("returns a published site with its nav in one D1 batch", async () => { + const { get, batch, directFirst, directAll } = setup(); + for (const key of ["pub", "pub-slug"]) { + const { res, body } = await get(`projects/${key}`); + expect(res.status).toBe(200); + const data = body!.data as { id: string; docs: { id: string; is_home: number }[]; folders: { id: string }[]; files: { id: string }[] }; + expect(data.id).toBe("pub"); + expect(data.docs.map(d => [d.id, d.is_home])).toEqual([["second", 0], ["home", 1]]); + expect(data.folders.map(f => f.id)).toEqual(["pub-folder"]); + expect(data.files.map(f => f.id)).toEqual(["pub-file"]); + } + expect(batch).toHaveBeenCalledTimes(2); + expect(directFirst).not.toHaveBeenCalled(); + expect(directAll).not.toHaveBeenCalled(); + }); + + it("404s an unpublished site and never reads its nav rows", async () => { + const { get, lastBatchRows } = setup(); + const { res } = await get("projects/priv"); + expect(res.status).toBe(404); + // [project, nav docs, nav folders, nav files] + expect(await lastBatchRows()).toEqual([0, 0, 0, 0]); + }); + + it("falls through to a published vanity-slug match when the id match is unpublished", async () => { + // Same outcome as the old `(id = ? OR vanity_slug = ?) AND published_at IS NOT NULL`. + const { get, project, doc } = setup(); + project("shared"); + doc("hidden-doc", "shared"); + project("other", { published: true, slug: "shared" }); + doc("other-doc", "other"); + const { res, body } = await get("projects/shared"); + expect(res.status).toBe(200); + const data = body!.data as { id: string; docs: { id: string }[] }; + expect(data.id).toBe("other"); + expect(data.docs.map(d => d.id)).toEqual(["other-doc"]); + }); + }); }); diff --git a/packages/api/src/routes/public.ts b/packages/api/src/routes/public.ts index cb08a25..24fe905 100644 --- a/packages/api/src/routes/public.ts +++ b/packages/api/src/routes/public.ts @@ -58,9 +58,11 @@ export async function handlePublicReport( if (session !== null && !(session instanceof Response)) reporterUserId = session.userId; } + // Explicit ISO created_at like every other content table (the column's + // datetime('now') default writes the zone-less "YYYY-MM-DD HH:MM:SS" form). await env.DB.prepare( - "INSERT INTO site_reports (id, project_id, reporter_user_id, reporter_ip, doc_id, note) VALUES (?, ?, ?, ?, ?, ?)", - ).bind(crypto.randomUUID(), project.id, reporterUserId, ip, docId, note).run(); + "INSERT INTO site_reports (id, project_id, reporter_user_id, reporter_ip, doc_id, note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", + ).bind(crypto.randomUUID(), project.id, reporterUserId, ip, docId, note, new Date().toISOString()).run(); return okResponse({ submitted: true }, 201); } @@ -103,6 +105,47 @@ interface PublicFile { content_stream_url?: string | null; } +type PublicNavDoc = Pick; + +// Resolve a URL's :idOrSlug segment to a single project id inside SQL, so a +// public read can go out as one db.batch with every statement keyed off the +// same project. An exact id match wins over a vanity slug (the slug regex +// doesn't forbid uuid-shaped slugs). Binds (idOrSlug, idOrSlug); both branches +// are index seeks (projects PK / idx_projects_vanity_slug). +const PROJECT_KEY_SQL = + "COALESCE((SELECT id FROM projects WHERE id = ?), (SELECT id FROM projects WHERE vanity_slug = ?))"; +// Same, but only ever resolves to a published site: when the id match is +// unpublished, a published vanity-slug match is used instead - the outcome of +// the old `(id = ? OR vanity_slug = ?) AND published_at IS NOT NULL` lookup. +const PUBLISHED_PROJECT_KEY_SQL = + "COALESCE((SELECT id FROM projects WHERE id = ? AND published_at IS NOT NULL), (SELECT id FROM projects WHERE vanity_slug = ? AND published_at IS NOT NULL))"; + +// The public site's nav lists (docs, folders, files) for the project `keySql` +// resolves to. Each statement re-checks published_at itself, so it returns no +// rows for an unpublished site whichever key expression is passed in. +function publishedNavStatements(db: D1Database, keySql: string, idOrSlug: string): D1PreparedStatement[] { + return [ + db.prepare( + `SELECT d.id, d.slug, d.title, d.folder_id, d.sidebar_position, CASE WHEN d.id = p.home_doc_id THEN 1 ELSE 0 END AS is_home + FROM projects p JOIN docs d ON d.project_id = p.id + WHERE p.id = ${keySql} AND p.published_at IS NOT NULL + ORDER BY CASE WHEN d.sidebar_position IS NULL THEN 1 ELSE 0 END, d.sidebar_position ASC, d.title ASC`, + ).bind(idOrSlug, idOrSlug), + db.prepare( + `SELECT f.id, f.name, f.parent_id + FROM projects p JOIN folders f ON f.project_id = p.id + WHERE p.id = ${keySql} AND p.published_at IS NOT NULL + ORDER BY f.name ASC`, + ).bind(idOrSlug, idOrSlug), + db.prepare( + `SELECT f.id, f.name, f.mime_type, f.size, f.folder_id + FROM projects p JOIN files f ON f.project_id = p.id + WHERE p.id = ${keySql} AND p.published_at IS NOT NULL + ORDER BY f.name ASC`, + ).bind(idOrSlug, idOrSlug), + ]; +} + // Enrich a list of published-site file rows with a presigned direct-from-R2 // stream URL for inline-safe videos (mirrors the authenticated GET /files/:id // path). Non-video / non-inline-safe rows are returned untouched. Presigning @@ -221,28 +264,23 @@ export async function handlePublic( }); } - // /public/projects/:id + // /public/projects/:id - one D1 round trip. The project row and the nav + // lists all key off the same published-only resolution of :idOrSlug, so an + // unpublished site yields no project row (404) and empty nav statements. if (parts[0] === "projects" && parts[1]) { - const projectId = parts[1]; - const project = await env.DB.prepare( - "SELECT id, name, description, published_at, vanity_slug, home_doc_id, graph_enabled, published_graph_enabled, logo_square_updated_at, logo_wide_updated_at FROM projects WHERE (id = ? OR vanity_slug = ?) AND published_at IS NOT NULL", - ).bind(projectId, projectId).first(); + const key = parts[1]; + const [projectRes, docsRes, foldersRes, filesRes] = await env.DB.batch([ + env.DB.prepare( + `SELECT id, name, description, published_at, vanity_slug, home_doc_id, graph_enabled, published_graph_enabled, logo_square_updated_at, logo_wide_updated_at + FROM projects WHERE id = ${PUBLISHED_PROJECT_KEY_SQL}`, + ).bind(key, key), + ...publishedNavStatements(env.DB, PUBLISHED_PROJECT_KEY_SQL, key), + ]); + const project = (projectRes.results as PublicProject[])[0]; if (!project) return errorResponse(Errors.NOT_FOUND); - const docs = await env.DB.prepare( - "SELECT id, slug, title, folder_id, sidebar_position, CASE WHEN ? = id THEN 1 ELSE 0 END AS is_home FROM docs WHERE project_id = ? ORDER BY CASE WHEN sidebar_position IS NULL THEN 1 ELSE 0 END, sidebar_position ASC, title ASC", - ).bind(project.home_doc_id ?? "", project.id).all>(); - - const folders = await env.DB.prepare( - "SELECT id, name, parent_id FROM folders WHERE project_id = ? ORDER BY name ASC", - ).bind(project.id).all(); - - const files = await env.DB.prepare( - "SELECT id, name, mime_type, size, folder_id FROM files WHERE project_id = ? ORDER BY name ASC", - ).bind(project.id).all(); - const enrichedFiles = await enrichFilesWithStreamUrls(env, files.results); - - return okResponse({ ...project, docs: docs.results, folders: folders.results, files: enrichedFiles }); + const enrichedFiles = await enrichFilesWithStreamUrls(env, filesRes.results as PublicFile[]); + return okResponse({ ...project, docs: docsRes.results as PublicNavDoc[], folders: foldersRes.results as PublicFolder[], files: enrichedFiles }); } // /public/docs/:projectId/:docIdOrSlug - the doc segment is either the doc's @@ -252,18 +290,33 @@ export async function handlePublic( const projectIdOrSlug = parts[1]; const docId = decodeURIComponent(parts[2]); - const project = await env.DB.prepare( - `SELECT p.id, p.name, p.published_at, p.vanity_slug, p.home_doc_id, p.graph_enabled, p.published_graph_enabled, p.logo_square_updated_at, p.logo_wide_updated_at, - CASE WHEN cd.status = 'active' THEN cd.hostname END AS custom_domain - FROM projects p LEFT JOIN project_custom_domains cd ON cd.project_id = p.id - WHERE p.id = ? OR p.vanity_slug = ?`, - ).bind(projectIdOrSlug, projectIdOrSlug).first & { custom_domain: string | null }>(); + // One D1 round trip for the project, the doc and (published sites only) + // the nav lists. Every statement keys off the same SQL resolution of + // :idOrSlug, so they can't disagree about which project they read. + const [projectRes, docRes, docsRes, foldersRes, filesRes] = await env.DB.batch([ + env.DB.prepare( + `SELECT p.id, p.name, p.published_at, p.vanity_slug, p.home_doc_id, p.graph_enabled, p.published_graph_enabled, p.logo_square_updated_at, p.logo_wide_updated_at, + CASE WHEN cd.status = 'active' THEN cd.hostname END AS custom_domain + FROM projects p LEFT JOIN project_custom_domains cd ON cd.project_id = p.id + WHERE p.id = ${PROJECT_KEY_SQL}`, + ).bind(projectIdOrSlug, projectIdOrSlug), + // JOIN form (not `project_id = ` in the WHERE) so both OR branches + // stay index seeks - the subquery form degrades the slug branch to a scan. + env.DB.prepare( + `SELECT d.id, d.slug, d.title, d.published_at, d.show_last_updated, d.show_heading, d.updated_at + FROM projects p JOIN docs d ON d.project_id = p.id + WHERE p.id = ${PROJECT_KEY_SQL} AND (d.id = ? OR d.slug = ?)`, + ).bind(projectIdOrSlug, projectIdOrSlug, docId, docId), + // Nav lists return no rows unless the SITE is published (the filter is in + // the SQL, not only in the sitePublished branch below), so a solo-published + // doc on an unpublished site never ships the site's nav. + ...publishedNavStatements(env.DB, PROJECT_KEY_SQL, projectIdOrSlug), + ]); + const project = (projectRes.results as (Pick & { custom_domain: string | null })[])[0]; if (!project) return errorResponse(Errors.NOT_FOUND); const projectId = project.id; - const doc = await env.DB.prepare( - "SELECT id, slug, title, published_at, show_last_updated, show_heading, updated_at FROM docs WHERE (id = ? OR slug = ?) AND project_id = ?", - ).bind(docId, docId, projectId).first(); + const doc = (docRes.results as (PublicDoc & { show_last_updated: number; show_heading: number; updated_at: string })[])[0]; if (!doc) return errorResponse(Errors.NOT_FOUND); const sitePublished = project.published_at !== null; @@ -279,25 +332,9 @@ export async function handlePublic( const content = r2Object ? await r2Object.text() : ""; const fm = parseFrontmatter(content); - let docs: Pick[] | null = null; - let folders: PublicFolder[] | null = null; - let files: PublicFile[] | null = null; - if (sitePublished) { - const docsResult = await env.DB.prepare( - "SELECT id, slug, title, folder_id, sidebar_position, CASE WHEN ? = id THEN 1 ELSE 0 END AS is_home FROM docs WHERE project_id = ? ORDER BY CASE WHEN sidebar_position IS NULL THEN 1 ELSE 0 END, sidebar_position ASC, title ASC", - ).bind(project.home_doc_id ?? "", projectId).all>(); - docs = docsResult.results; - - const foldersResult = await env.DB.prepare( - "SELECT id, name, parent_id FROM folders WHERE project_id = ? ORDER BY name ASC", - ).bind(projectId).all(); - folders = foldersResult.results; - - const filesResult = await env.DB.prepare( - "SELECT id, name, mime_type, size, folder_id FROM files WHERE project_id = ? ORDER BY name ASC", - ).bind(projectId).all(); - files = await enrichFilesWithStreamUrls(env, filesResult.results); - } + const docs = sitePublished ? (docsRes.results as PublicNavDoc[]) : null; + const folders = sitePublished ? (foldersRes.results as PublicFolder[]) : null; + const files = sitePublished ? await enrichFilesWithStreamUrls(env, filesRes.results as PublicFile[]) : null; return okResponse({ doc: { id: doc.id, slug: doc.slug ?? null, title: doc.title, display_title: fm.title ?? null, hide_title: fm.hide_title ?? null, description: fm.description ?? null, image: fm.image ?? null, content, showHeading: doc.show_heading !== 0, showLastUpdated: doc.show_last_updated !== 0, updatedAt: doc.updated_at }, diff --git a/packages/frontend/src/layouts/DocsLayout.tsx b/packages/frontend/src/layouts/DocsLayout.tsx index 5752698..2e47a13 100644 --- a/packages/frontend/src/layouts/DocsLayout.tsx +++ b/packages/frontend/src/layouts/DocsLayout.tsx @@ -49,9 +49,14 @@ import { UserAvatar } from "@/components/UserAvatar"; import { UserProfileCard } from "@/components/UserProfileCard"; import { ProjectSquareLogo } from "@/components/ProjectSquareLogo"; -interface Project { +// A row of GET /api/projects. doc_count / member_count are only present when +// the list was fetched with `?counts=1` (the dashboard cards). +export interface ProjectListItem { id: string; name: string; + description: string | null; + doc_count?: number; + member_count?: number; role: string; published_at: string | null; changelog_mode: string; @@ -65,6 +70,7 @@ interface Project { organization_id: string | null; organization_name: string | null; } +type Project = ProjectListItem; // Orgs the current user can create a site under (admin+ only - the API gates // site creation in an org at admin rank). Used to populate the New Site picker. @@ -151,6 +157,11 @@ export interface DocsLayoutContext { headerActionSlot: HTMLElement | null; openCreateSite: () => void; openCreateOrg: () => void; + // The caller's site list (GET /api/projects), shared with DashboardPage so + // the dashboard doesn't refetch what the sidebar already loaded. Fetched + // with doc/member counts whenever the dashboard is entered. + projects: ProjectListItem[]; + setProjects: Dispatch>; } function ProjectSwitcher({ @@ -438,16 +449,28 @@ export function DocsLayout() { writeThemePrefsCookie(prefs); }, [themeMode, themeCustomColor]); + // Site list: loaded once on boot, and again (with the dashboard-only doc / + // member counts) every time the dashboard is entered - DashboardPage reads + // it from the outlet context instead of fetching its own copy. Leaving the + // dashboard doesn't refetch. The sequence number drops a slower, older + // response that would otherwise overwrite a newer one. + const onDashboard = useMatch("/dashboard") !== null; + const projectsLoaded = useRef(false); + const projectsFetchSeq = useRef(0); useEffect(() => { if (!getToken()) return; - apiFetchJson("/api/projects") + if (projectsLoaded.current && !onDashboard) return; + projectsLoaded.current = true; + const seq = ++projectsFetchSeq.current; + apiFetchJson(onDashboard ? "/api/projects?counts=1" : "/api/projects") .then(result => { + if (seq !== projectsFetchSeq.current) return; // 401/403 redirects to /login are handled inside apiFetch; the page is // unloading by the time we get here, so just ignore those statuses. if (result.ok && result.data) setProjects([...result.data].sort((a, b) => b.is_favourite - a.is_favourite)); }) .catch(() => {}); - }, [navigate]); // eslint-disable-line react-hooks/exhaustive-deps + }, [onDashboard]); useEffect(() => { if (!projectId) { setDocs([]); setFolders([]); return; } @@ -637,6 +660,8 @@ export function DocsLayout() { headerActionSlot: headerActionEl, openCreateSite: () => setCreating(true), openCreateOrg: () => setCreatingOrg(true), + projects, + setProjects, }; // Shared by the project and overview sidebars so the palette entry looks diff --git a/packages/frontend/src/pages/DashboardPage.test.tsx b/packages/frontend/src/pages/DashboardPage.test.tsx new file mode 100644 index 0000000..93a9c1f --- /dev/null +++ b/packages/frontend/src/pages/DashboardPage.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { useState } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter, Routes, Route, Outlet } from "react-router-dom"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import type { ProjectListItem } from "@/layouts/DocsLayout"; +import { DashboardPage } from "./DashboardPage"; + +vi.mock("@/lib/auth", () => ({ getToken: () => "tok" })); + +function site(overrides: Partial): ProjectListItem { + return { + id: "s1", + name: "Handbook", + description: null, + role: "owner", + published_at: null, + changelog_mode: "off", + ai_enabled: 0, + ai_summarization_type: "manual", + graph_enabled: 0, + is_favourite: 0, + is_hidden: 0, + features: 0, + logo_square_updated_at: null, + organization_id: null, + organization_name: null, + ...overrides, + }; +} + +// Stands in for DocsLayout: owns the site list and hands it to the page via +// the outlet context, exactly as the real layout does. +function Layout({ initial }: { initial: ProjectListItem[] }) { + const [projects, setProjects] = useState(initial); + return {}, openCreateOrg: () => {}, projects, setProjects }} />; +} + +function renderDashboard(projects: ProjectListItem[]) { + return render( + + + + }> + } /> + + + + , + ); +} + +let fetchMock: ReturnType; +beforeEach(() => { + fetchMock = vi.fn(async () => Response.json({ ok: true, data: [] })); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("DashboardPage", () => { + it("renders the layout's site list without fetching /api/projects itself", async () => { + renderDashboard([site({ id: "s1", name: "Handbook", doc_count: 3, member_count: 2 })]); + expect(await screen.findByText("Handbook")).toBeInTheDocument(); + expect(screen.getByText("3 docs")).toBeInTheDocument(); + expect(screen.getByLabelText("2 members")).toBeInTheDocument(); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); + const urls = fetchMock.mock.calls.map(c => String(c[0])); + expect(urls.some(u => u.startsWith("/api/projects"))).toBe(false); + }); + + it("omits the count badges until the counted list has loaded", async () => { + renderDashboard([site({ id: "s1", name: "Handbook" })]); + expect(await screen.findByText("Handbook")).toBeInTheDocument(); + expect(screen.queryByText(/\bdocs?\b/)).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/members?$/)).not.toBeInTheDocument(); + }); + + it("favouriting updates the shared list (and so the sidebar)", async () => { + renderDashboard([ + site({ id: "s1", name: "Alpha", doc_count: 1, member_count: 1 }), + site({ id: "s2", name: "Beta", doc_count: 1, member_count: 1 }), + ]); + await screen.findByText("Beta"); + await userEvent.click(screen.getAllByRole("button", { name: /favourite/i })[1]); + expect(fetchMock).toHaveBeenCalledWith("/api/projects/s2/favourite", expect.objectContaining({ method: "PATCH" })); + // Favourites sort first. + const titles = screen.getAllByRole("heading", { level: 3 }).map(h => h.textContent); + expect(titles).toEqual(["Beta", "Alpha"]); + }); +}); diff --git a/packages/frontend/src/pages/DashboardPage.tsx b/packages/frontend/src/pages/DashboardPage.tsx index 6eeb78e..2415261 100644 --- a/packages/frontend/src/pages/DashboardPage.tsx +++ b/packages/frontend/src/pages/DashboardPage.tsx @@ -6,22 +6,9 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getToken } from "@/lib/auth"; -import type { DocsLayoutContext } from "@/layouts/DocsLayout"; +import type { DocsLayoutContext, ProjectListItem } from "@/layouts/DocsLayout"; -interface Project { - id: string; - name: string; - description: string | null; - doc_count: number; - member_count: number; - published_at: string | null; - ai_enabled: number; - is_favourite: number; - is_hidden: number; - features: number; - organization_id: string | null; - organization_name: string | null; -} +type Project = ProjectListItem; const REALTIME_FEATURE = 4; @@ -38,12 +25,13 @@ interface Org { } export function DashboardPage() { - const [projects, setProjects] = useState([]); const [orgs, setOrgs] = useState([]); const [pendingCount, setPendingCount] = useState(0); const [hiddenOpen, setHiddenOpen] = useState(false); const navigate = useNavigate(); - const { openCreateSite, openCreateOrg } = useOutletContext(); + // The site list lives in DocsLayout: it loads it for the sidebar and + // refetches it with doc/member counts whenever the dashboard is entered. + const { openCreateSite, openCreateOrg, projects, setProjects } = useOutletContext(); function sortByFavourite(list: Project[]) { return [...list].sort((a, b) => b.is_favourite - a.is_favourite); @@ -83,12 +71,6 @@ export function DashboardPage() { useEffect(() => { const token = getToken(); if (!token) return; - fetch("/api/projects", { headers: { Authorization: `Bearer ${token}` } }) - .then(r => r.json()) - .then((json: { ok: boolean; data?: Project[] }) => { - if (json.ok && json.data) setProjects(json.data); - }) - .catch(() => {}); fetch("/api/pending-invites", { headers: { Authorization: `Bearer ${token}` } }) .then(r => r.json()) .then((json: { ok: boolean; data?: unknown[] }) => { @@ -131,9 +113,13 @@ export function DashboardPage() { {project.name} - - {project.doc_count} {project.doc_count === 1 ? "doc" : "docs"} - + {/* Counts arrive with the dashboard's ?counts=1 load; a list reused + from the sidebar's boot fetch has none until that lands. */} + {project.doc_count != null && ( + + {project.doc_count} {project.doc_count === 1 ? "doc" : "docs"} + + )} @@ -246,21 +232,23 @@ export function DashboardPage() { )} - - -
- - {project.member_count} -
-
- - {project.member_count} {project.member_count === 1 ? "member" : "members"} - -
+ {project.member_count != null && ( + + +
+ + {project.member_count} +
+
+ + {project.member_count} {project.member_count === 1 ? "member" : "members"} + +
+ )}