Add document reference parsing + documents board route (PAP-10569)

Path-based parser for /<co>/documents/<id>?from=issue:<key> references in
@paperclipai/shared, and register "documents" as a board route root so the
library nav and document deep-links keep the company prefix instead of
404-ing on the unprefixed catch-all.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-07 01:54:58 +00:00
1 parent 3eb57fce1f
commit 0245d1f3af
5 files changed
+126

No files matched your search

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { buildDocumentReferenceHref, parseDocumentReferenceHref } from "./document-references.js";
describe("parseDocumentReferenceHref", () => {
it("parses a company-prefixed documents path", () => {
expect(parseDocumentReferenceHref("/PAP/documents/abc-123")).toEqual({
documentId: "abc-123",
fromIssueKey: null,
});
});
it("parses a company-relative documents path", () => {
expect(parseDocumentReferenceHref("/documents/abc-123")).toEqual({
documentId: "abc-123",
fromIssueKey: null,
});
});
it("extracts the originating issue key from ?from=issue:<key>", () => {
expect(parseDocumentReferenceHref("/documents/abc-123?from=issue:plan")).toEqual({
documentId: "abc-123",
fromIssueKey: "plan",
});
});
it("parses absolute urls", () => {
expect(parseDocumentReferenceHref("https://app.example.com/ACME/documents/doc_9")).toEqual({
documentId: "doc_9",
fromIssueKey: null,
});
});
it("returns null for non-document paths", () => {
expect(parseDocumentReferenceHref("/PAP/issues/PAP-1")).toBeNull();
expect(parseDocumentReferenceHref("/documents")).toBeNull();
expect(parseDocumentReferenceHref("not a url at all ::::")).toBeNull();
});
it("round-trips with buildDocumentReferenceHref", () => {
expect(buildDocumentReferenceHref("abc-123")).toBe("/documents/abc-123");
expect(buildDocumentReferenceHref("abc-123", "plan")).toBe("/documents/abc-123?from=issue:plan");
const built = buildDocumentReferenceHref("abc-123", "spec");
expect(parseDocumentReferenceHref(built)).toEqual({ documentId: "abc-123", fromIssueKey: "spec" });
});
});
@@ -0,0 +1,60 @@
/**
* Path-based references to company documents, e.g. `/PAP/documents/<id>` or the
* company-relative `/documents/<id>`. Mirrors `issue-references.ts` so comment
* bodies can render a cross-issue document mention chip. The `<id>` segment is
* the company document id used by the documents library detail route.
*/
export interface ParsedDocumentReference {
documentId: string;
/** When the href carries `?from=issue:<key>`, the originating issue document key. */
fromIssueKey: string | null;
}
/** Document ids are UUIDs in practice; accept any non-empty url-safe slug to stay forgiving. */
const DOCUMENT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
function parseFromParam(value: string | null): string | null {
if (!value) return null;
const trimmed = value.trim();
const match = /^issue:(.+)$/i.exec(trimmed);
if (!match) return null;
const key = match[1]?.trim();
return key ? key : null;
}
export function buildDocumentReferenceHref(documentId: string, fromIssueKey?: string | null): string {
const id = documentId.trim();
const key = fromIssueKey?.trim();
return key ? `/documents/${id}?from=issue:${encodeURIComponent(key)}` : `/documents/${id}`;
}
export function parseDocumentReferenceHref(href: string): ParsedDocumentReference | null {
const raw = href.trim();
if (!raw) return null;
let url: URL;
try {
url = raw.startsWith("/") ? new URL(raw, "https://paperclip.invalid") : new URL(raw);
} catch {
return null;
}
const segments = url.pathname
.split("/")
.map((segment) => segment.trim())
.filter(Boolean);
for (let index = 0; index < segments.length - 1; index += 1) {
if (segments[index]?.toLowerCase() !== "documents") continue;
const candidate = segments[index + 1] ?? "";
if (DOCUMENT_ID_RE.test(candidate)) {
return {
documentId: candidate,
fromIssueKey: parseFromParam(url.searchParams.get("from")),
};
}
}
return null;
}
+6
View File
@@ -813,6 +813,12 @@ export {
type IssueReferenceMatch,
} from "./issue-references.js";
export {
buildDocumentReferenceHref,
parseDocumentReferenceHref,
type ParsedDocumentReference,
} from "./document-references.js";
export {
anchorSnapshotToSelector,
createDocumentAnchorSelector,
+14
View File
@@ -65,6 +65,20 @@ describe("company routes", () => {
);
});
// Regression for PAP-10569: document deep-links (`/documents/<id>?from=issue:<key>`)
// and the library nav must keep the company prefix so they resolve under
// `/:companyPrefix` instead of 404-ing on the unprefixed catch-all.
it("treats /documents as a board route that needs a company prefix", () => {
expect(isBoardPathWithoutPrefix("/documents")).toBe(true);
expect(isBoardPathWithoutPrefix("/documents/doc-123")).toBe(true);
expect(extractCompanyPrefixFromPath("/documents/doc-123")).toBeNull();
expect(applyCompanyPrefix("/documents", "PAP")).toBe("/PAP/documents");
expect(applyCompanyPrefix("/documents/doc-123?from=issue:plan", "PAP")).toBe(
"/PAP/documents/doc-123?from=issue:plan",
);
expect(toCompanyRelativePath("/PAP/documents/doc-123")).toBe("/documents/doc-123");
});
it("treats /artifacts as a board route that needs a company prefix", () => {
expect(isBoardPathWithoutPrefix("/artifacts")).toBe(true);
expect(extractCompanyPrefixFromPath("/artifacts")).toBeNull();
+1
View File
@@ -10,6 +10,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"workspaces",
"execution-workspaces",
"issues",
"documents",
"routines",
"goals",
"artifacts",