diff --git a/packages/db/src/migrations/0102_backfill_document_type_from_issue_key.sql b/packages/db/src/migrations/0102_backfill_document_type_from_issue_key.sql new file mode 100644 index 00000000000..0a9f55cbe74 --- /dev/null +++ b/packages/db/src/migrations/0102_backfill_document_type_from_issue_key.sql @@ -0,0 +1,19 @@ +-- Backfill documents.document_type from their issue-document key so the +-- Documents library "Type" filter matches the key chip rendered in each row. +-- Auto-generated issue docs were created with document_type = 'other' +-- regardless of key (plan/spec/brief/report), so Type=Plan never matched. +-- Idempotent: only rows still at the default 'other' are touched, preserving +-- any document_type a user set manually. Locked-document fallback keys carry a +-- numeric suffix (e.g. plan-2), which is stripped before matching. +UPDATE "documents" AS d +SET "document_type" = CASE regexp_replace(lower(btrim(id."key")), '-[0-9]+$', '') + WHEN 'plan' THEN 'plan' + WHEN 'spec' THEN 'spec' + WHEN 'brief' THEN 'brief' + WHEN 'report' THEN 'report' + ELSE d."document_type" + END +FROM "issue_documents" AS id +WHERE id."document_id" = d."id" + AND d."document_type" = 'other' + AND regexp_replace(lower(btrim(id."key")), '-[0-9]+$', '') IN ('plan', 'spec', 'brief', 'report'); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index b53d000276e..870c5e58c78 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -715,6 +715,13 @@ "when": 1780794600000, "tag": "0101_document_suggestion_resolved_status", "breakpoints": true + }, + { + "idx": 102, + "version": "7", + "when": 1780794900000, + "tag": "0102_backfill_document_type_from_issue_key", + "breakpoints": true } ] } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 958acdc10a6..1989c27fbff 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -371,6 +371,28 @@ export type DocumentStatus = (typeof DOCUMENT_STATUSES)[number]; export const DOCUMENT_TYPES = ["plan", "spec", "brief", "report", "other"] as const; export type DocumentType = (typeof DOCUMENT_TYPES)[number]; +/** + * Categorical {@link DocumentType} values an issue-document key can map onto. + * Excludes the catch-all `other`, which is the fallback for any unmatched key. + */ +const ISSUE_DOCUMENT_KEY_TYPE_MAP: Record = { + plan: "plan", + spec: "spec", + brief: "brief", + report: "report", +}; + +/** + * Derive the categorical {@link DocumentType} for an auto-generated issue + * document from its key (e.g. `plan` → `plan`). Locked-document fallback keys + * carry a numeric suffix (`plan-2`), so that is stripped before matching. + * Anything that doesn't match a known category falls back to `other`. + */ +export function documentTypeForIssueDocumentKey(key: string): DocumentType { + const base = key.trim().toLowerCase().replace(/-[0-9]+$/, ""); + return ISSUE_DOCUMENT_KEY_TYPE_MAP[base] ?? "other"; +} + export const DOCUMENT_LINK_TARGET_TYPES = [ "issue", "project", diff --git a/packages/shared/src/document-type.test.ts b/packages/shared/src/document-type.test.ts new file mode 100644 index 00000000000..a45db2d1477 --- /dev/null +++ b/packages/shared/src/document-type.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { documentTypeForIssueDocumentKey } from "./constants.js"; + +describe("documentTypeForIssueDocumentKey", () => { + it("maps known categorical keys to their document type", () => { + expect(documentTypeForIssueDocumentKey("plan")).toBe("plan"); + expect(documentTypeForIssueDocumentKey("spec")).toBe("spec"); + expect(documentTypeForIssueDocumentKey("brief")).toBe("brief"); + expect(documentTypeForIssueDocumentKey("report")).toBe("report"); + }); + + it("strips the locked-document numeric suffix before matching", () => { + expect(documentTypeForIssueDocumentKey("plan-2")).toBe("plan"); + expect(documentTypeForIssueDocumentKey("report-17")).toBe("report"); + }); + + it("normalizes case and whitespace", () => { + expect(documentTypeForIssueDocumentKey(" Plan ")).toBe("plan"); + }); + + it("falls back to other for unknown or system keys", () => { + expect(documentTypeForIssueDocumentKey("continuation-summary")).toBe("other"); + expect(documentTypeForIssueDocumentKey("browsable-files")).toBe("other"); + expect(documentTypeForIssueDocumentKey("planning")).toBe("other"); + expect(documentTypeForIssueDocumentKey("plan-notes")).toBe("other"); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d7eb01fd9a2..713c6fc5dd6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -96,6 +96,7 @@ export { DOCUMENT_SUGGESTION_STATUSES, DOCUMENT_STATUSES, DOCUMENT_TYPES, + documentTypeForIssueDocumentKey, DOCUMENT_LINK_TARGET_TYPES, ISSUE_EXECUTION_POLICY_MODES, ISSUE_EXECUTION_STAGE_TYPES, diff --git a/server/src/__tests__/documents-service.test.ts b/server/src/__tests__/documents-service.test.ts index 752671efde3..783245667cb 100644 --- a/server/src/__tests__/documents-service.test.ts +++ b/server/src/__tests__/documents-service.test.ts @@ -196,6 +196,41 @@ describeEmbeddedPostgres("documentService system issue documents", () => { })); }); + it("derives documentType from the issue document key so the Type filter matches", async () => { + const { issueId } = await createIssueWithDocuments(); + + const plan = await svc.getIssueDocumentByKey(issueId, "plan"); + const summary = await svc.getIssueDocumentByKey(issueId, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY); + expect(plan?.documentType).toBe("plan"); + // Unknown / system keys stay categorical "other". + expect(summary?.documentType).toBe("other"); + + // Locked-document fallback keys (plan-2) inherit the same type. + const locked = await svc.lockIssueDocument({ issueId, key: "plan", lockedByUserId: "board-user" }); + const fallback = await svc.upsertIssueDocument({ + issueId, + key: "plan", + title: "Plan", + format: "markdown", + body: "# Replacement plan", + baseRevisionId: locked.document.latestRevisionId, + lockedDocumentStrategy: "create_new_document", + }); + expect(fallback.document.key).toBe("plan-2"); + expect(fallback.document.documentType).toBe("plan"); + + // The company library Type filter (documentType) now matches plan docs. + const planDocs = await svc.listCompanyDocuments(plan!.companyId, { + type: ["plan"], + limit: 50, + offset: 0, + }); + const planIds = planDocs.map((doc) => doc.id); + expect(planIds).toContain(plan!.id); + expect(planIds).toContain(fallback.document.id); + expect(planIds).not.toContain(summary!.id); + }); + it("maintains document links for issue-document compatibility and company library queries", async () => { const { issueId } = await createIssueWithDocuments(); diff --git a/server/src/services/documents.ts b/server/src/services/documents.ts index 47abe294635..d379fb35959 100644 --- a/server/src/services/documents.ts +++ b/server/src/services/documents.ts @@ -23,6 +23,7 @@ import { type DocumentLinkTargetType, type DocumentStatus, type DocumentType, + documentTypeForIssueDocumentKey, isSystemIssueDocumentKey, issueDocumentKeySchema, } from "@paperclipai/shared"; @@ -863,6 +864,7 @@ export function documentService(db: Db) { companyId: issue.companyId, title: input.title ?? null, format: input.format, + documentType: documentTypeForIssueDocumentKey(fallbackKey), latestBody: input.body, latestRevisionId: null, latestRevisionNumber: 1, @@ -1047,6 +1049,7 @@ export function documentService(db: Db) { companyId: issue.companyId, title: input.title ?? null, format: input.format, + documentType: documentTypeForIssueDocumentKey(key), latestBody: input.body, latestRevisionId: null, latestRevisionNumber: 1,