From 7012aa6e4361d0d14234bca2f21216816137ec10 Mon Sep 17 00:00:00 2001 From: James Morton Date: Wed, 9 Sep 2026 23:31:04 +0100 Subject: [PATCH 1/7] feat(ids): serialize help-center articles as article_ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate and emit article_ TypeIDs from the same UUID; accept retired kb_article_ ids as an inbound alias. No database migration — prefixes are applied at the ORM boundary. Co-authored-by: Cursor --- .../__tests__/copilot-usage-card.test.tsx | 12 +-- .../article-feedback-reasons-dialog.test.tsx | 4 +- .../article-performance-table.test.tsx | 8 +- .../__tests__/ask-ai-stream.test.ts | 8 +- .../help-center-article-feedback.test.tsx | 2 +- .../widget/__tests__/widget-compose.test.ts | 10 +-- .../src/components/widget/widget-compose.ts | 2 +- .../src/lib/server/domains/api/validation.ts | 15 ++-- .../__tests__/assistant.runtime.test.ts | 73 +++++++++---------- .../__tests__/assistant.tools.test.ts | 30 ++++---- .../__tests__/retrieval-sources.test.ts | 22 +++--- .../assistant/__tests__/retrieval.test.ts | 12 +-- .../assistant/__tests__/synthesis.test.ts | 60 +++++++-------- .../domains/export/__tests__/entities.test.ts | 4 +- ...lp-center-article-feedback.service.test.ts | 14 ++-- ...p-center-article-performance.query.test.ts | 8 +- .../help-center-article-search-parity.test.ts | 26 +++---- .../help-center-article.query.test.ts | 14 ++-- .../help-center-article.service.test.ts | 70 +++++++++--------- ...help-center-auto-translate.service.test.ts | 24 +++--- .../help-center-embedding-logging.test.ts | 4 +- .../help-center-locale.query.test.ts | 32 ++++---- ...help-center-redirect-rules.service.test.ts | 22 +++--- .../help-center-related-articles.test.ts | 10 +-- .../help-center-search-ranked.test.ts | 8 +- .../help-center-translations.service.test.ts | 26 +++---- .../src/lib/server/mcp/tools/help-center.ts | 24 +++--- apps/web/src/lib/server/mcp/tools/search.ts | 11 +-- .../schemas/__tests__/help-center.test.ts | 8 +- .../widget/__tests__/article-ref.test.ts | 12 +-- apps/web/src/lib/shared/widget/article-ref.ts | 29 ++------ .../v1/help-center/__tests__/articles.test.ts | 56 +++++++------- .../articles/$articleId.feedback.ts | 2 +- .../api/v1/help-center/articles/$articleId.ts | 6 +- .../api/widget/__tests__/kb-ask.test.ts | 28 +++---- packages/db/src/schema/kb.ts | 6 +- packages/ids/src/__tests__/core.test.ts | 18 +++++ packages/ids/src/__tests__/zod.test.ts | 8 ++ packages/ids/src/core.ts | 15 ++-- packages/ids/src/index.ts | 13 +++- packages/ids/src/prefixes.ts | 29 +++++++- packages/ids/src/types.ts | 6 +- packages/ids/src/zod.ts | 14 ++-- 43 files changed, 429 insertions(+), 376 deletions(-) diff --git a/apps/web/src/components/admin/automation/__tests__/copilot-usage-card.test.tsx b/apps/web/src/components/admin/automation/__tests__/copilot-usage-card.test.tsx index 3ead3dc23f..277dbee4d4 100644 --- a/apps/web/src/components/admin/automation/__tests__/copilot-usage-card.test.tsx +++ b/apps/web/src/components/admin/automation/__tests__/copilot-usage-card.test.tsx @@ -43,16 +43,16 @@ const METRICS = { ], topCitedSources: [ { - id: 'kb_article_1', + id: 'article_1', title: 'Resetting your password', - url: '/admin/help-center/articles/kb_article_1', + url: '/admin/help-center/articles/article_1', questions: 18, insertRate: 33, }, { - id: 'kb_article_2', + id: 'article_2', title: 'Exporting a report', - url: '/admin/help-center/articles/kb_article_2', + url: '/admin/help-center/articles/article_2', questions: 4, insertRate: null, }, @@ -160,14 +160,14 @@ describe('CopilotUsageCard', () => { const topRow = (await within(table).findByText('Resetting your password')).closest('tr')! expect(within(topRow).getByRole('link')).toHaveAttribute( 'href', - '/admin/help-center/articles/kb_article_1' + '/admin/help-center/articles/article_1' ) expect(within(topRow).getByText('18')).toBeInTheDocument() expect(within(topRow).getByText('33%')).toBeInTheDocument() const secondRow = within(table).getByText('Exporting a report').closest('tr')! expect(within(secondRow).getByRole('link')).toHaveAttribute( 'href', - '/admin/help-center/articles/kb_article_2' + '/admin/help-center/articles/article_2' ) expect(within(secondRow).getByText('4')).toBeInTheDocument() // A source with no in-range insert (or none logged before the field diff --git a/apps/web/src/components/admin/help-center/__tests__/article-feedback-reasons-dialog.test.tsx b/apps/web/src/components/admin/help-center/__tests__/article-feedback-reasons-dialog.test.tsx index 2d6eb637e7..470538d3f8 100644 --- a/apps/web/src/components/admin/help-center/__tests__/article-feedback-reasons-dialog.test.tsx +++ b/apps/web/src/components/admin/help-center/__tests__/article-feedback-reasons-dialog.test.tsx @@ -29,7 +29,7 @@ function renderDialog() { return render( {}} /> @@ -61,7 +61,7 @@ describe('ArticleFeedbackReasonsDialog', () => { expect(rendered[0]).toContain('The screenshots are out of date') expect(rendered[1]).toContain('Missing the CLI flag') - expect(listReasons).toHaveBeenCalledWith({ data: { articleId: 'kb_article_1' } }) + expect(listReasons).toHaveBeenCalledWith({ data: { articleId: 'article_1' } }) }) it('says so when no unhelpful vote came with an explanation', async () => { diff --git a/apps/web/src/components/admin/help-center/__tests__/article-performance-table.test.tsx b/apps/web/src/components/admin/help-center/__tests__/article-performance-table.test.tsx index 791348d17b..d93d156b5f 100644 --- a/apps/web/src/components/admin/help-center/__tests__/article-performance-table.test.tsx +++ b/apps/web/src/components/admin/help-center/__tests__/article-performance-table.test.tsx @@ -28,7 +28,7 @@ import { ArticlePerformanceTable } from '../article-performance-table' const ROWS = [ { - id: 'kb_article_1', + id: 'article_1', slug: 'getting-started', title: 'Getting started', status: 'published' as const, @@ -38,7 +38,7 @@ const ROWS = [ notHelpfulCount: 10, }, { - id: 'kb_article_2', + id: 'article_2', slug: 'billing-faq', title: 'Billing FAQ', status: 'published' as const, @@ -48,7 +48,7 @@ const ROWS = [ notHelpfulCount: 20, }, { - id: 'kb_article_3', + id: 'article_3', slug: 'api-keys', title: 'API keys', status: 'draft' as const, @@ -99,7 +99,7 @@ describe('ArticlePerformanceTable', () => { it('omits the worst-reacted callout when no article has received any votes', async () => { hoisted.listArticlePerformanceFn.mockResolvedValue([ - { ...ROWS[2], id: 'kb_article_4', title: 'Untouched article' }, + { ...ROWS[2], id: 'article_4', title: 'Untouched article' }, ]) renderWithClient() diff --git a/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts b/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts index 8ac452e0e9..76ef6f85b7 100644 --- a/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts +++ b/apps/web/src/components/help-center/__tests__/ask-ai-stream.test.ts @@ -16,7 +16,7 @@ afterEach(() => { }) const META: AskAiSourceMeta = { - articleId: 'kb_article_1', + articleId: 'article_1', urlId: 1, title: 'Refund policy', slug: 'refund-policy', @@ -34,7 +34,7 @@ describe('useAskAi', () => { const answer = { kind: 'grounded', answer: 'Do the thing.', - sources: [{ articleId: 'kb_article_1' }], + sources: [{ articleId: 'article_1' }], } stubAguiFetch( aguiRun({ @@ -63,7 +63,7 @@ describe('useAskAi', () => { kind: 'grounded', answer: 'A.', // The model cited an id that never appeared in the snapshot join. - sources: [{ articleId: 'kb_article_1' }, { articleId: 'kb_ghost' }], + sources: [{ articleId: 'article_1' }, { articleId: 'kb_ghost' }], } stubAguiFetch( aguiRun({ middle: [snapshotChunk([META]), ...structuredDeltas(answer)], result: answer }) @@ -134,7 +134,7 @@ describe('useAskAi', () => { }) it('reset returns the hook to idle', async () => { - const answer = { kind: 'grounded', answer: 'A.', sources: [{ articleId: 'kb_article_1' }] } + const answer = { kind: 'grounded', answer: 'A.', sources: [{ articleId: 'article_1' }] } stubAguiFetch( aguiRun({ middle: [snapshotChunk([META]), ...structuredDeltas(answer)], result: answer }) ) diff --git a/apps/web/src/components/help-center/__tests__/help-center-article-feedback.test.tsx b/apps/web/src/components/help-center/__tests__/help-center-article-feedback.test.tsx index e39196c423..c6938bdb73 100644 --- a/apps/web/src/components/help-center/__tests__/help-center-article-feedback.test.tsx +++ b/apps/web/src/components/help-center/__tests__/help-center-article-feedback.test.tsx @@ -32,7 +32,7 @@ afterEach(() => { function renderFeedback() { return render( {}}> - + ) } diff --git a/apps/web/src/components/widget/__tests__/widget-compose.test.ts b/apps/web/src/components/widget/__tests__/widget-compose.test.ts index 3a385b346e..f68d1f5a28 100644 --- a/apps/web/src/components/widget/__tests__/widget-compose.test.ts +++ b/apps/web/src/components/widget/__tests__/widget-compose.test.ts @@ -96,14 +96,14 @@ describe('resolveOpenCommand', () => { }) it('forwards an article TypeID the same way as a post TypeID', () => { - const articleId = generateId('kb_article') - const publicId = `article_${articleId.slice('kb_article_'.length)}` - expect(resolveOpenCommand({ articleId: publicId }, allTabs)).toEqual({ + const articleId = generateId('article') + const legacyId = `kb_article_${articleId.slice('article_'.length)}` + expect(resolveOpenCommand({ articleId }, allTabs)).toEqual({ type: 'article', - articleId: publicId, + articleId, }) - expect(isArticleTypeId(publicId)).toBe(true) expect(isArticleTypeId(articleId)).toBe(true) + expect(isArticleTypeId(legacyId)).toBe(true) expect(isArticleTypeId('art_01h...')).toBe(false) expect(isArticleTypeId('pricing')).toBe(false) }) diff --git a/apps/web/src/components/widget/widget-compose.ts b/apps/web/src/components/widget/widget-compose.ts index 1b57ff20a1..6c8679028a 100644 --- a/apps/web/src/components/widget/widget-compose.ts +++ b/apps/web/src/components/widget/widget-compose.ts @@ -27,7 +27,7 @@ export type WidgetOpenPayload = { export type WidgetOpenCommand = | { type: 'new-post'; title?: string; body?: string; boardSlug?: string } | { type: 'post'; postId: string } - | { type: 'article'; articleId: string } // slug or `article_` / `kb_article_` TypeID + | { type: 'article'; articleId: string } // slug or `article_` TypeID (`kb_article_` still accepted) | { type: 'changelog'; entryId?: string } | { type: 'help'; query?: string } | { type: 'messenger' } diff --git a/apps/web/src/lib/server/domains/api/validation.ts b/apps/web/src/lib/server/domains/api/validation.ts index b47486b0ed..45ab54ce61 100644 --- a/apps/web/src/lib/server/domains/api/validation.ts +++ b/apps/web/src/lib/server/domains/api/validation.ts @@ -1,10 +1,11 @@ -import { isValidTypeId, type IdPrefix } from '@quackback/ids' +import { ensureTypeId, isValidTypeId, type IdPrefix } from '@quackback/ids' import { ValidationError } from '@/lib/shared/errors' /** * Validate a required TypeID parameter. * Throws ValidationError if the format is invalid. - * Returns the value cast to T so callers don't need a separate `as TypeId` cast. + * Alias prefixes (e.g. `kb_article_` → `article_`) are rewritten to the + * canonical prefix so callers always see the catalogue form. */ export function parseTypeId( value: string, @@ -14,7 +15,7 @@ export function parseTypeId( if (!isValidTypeId(value, prefix)) { throw new ValidationError('VALIDATION_ERROR', `Invalid ${paramName} format`) } - return value as T + return ensureTypeId(value, prefix) as T } /** @@ -31,7 +32,7 @@ export function parseOptionalTypeId( if (!isValidTypeId(value, prefix)) { throw new ValidationError('VALIDATION_ERROR', `Invalid ${paramName} format`) } - return value as T + return ensureTypeId(value, prefix) as T } /** @@ -46,10 +47,10 @@ export function parseTypeIdArray( paramName = 'IDs' ): T[] | undefined { if (values === undefined) return undefined - for (const value of values) { + return values.map((value) => { if (!isValidTypeId(value, prefix)) { throw new ValidationError('VALIDATION_ERROR', `Invalid ${paramName} format`) } - } - return values as T[] + return ensureTypeId(value, prefix) as T + }) } diff --git a/apps/web/src/lib/server/domains/assistant/__tests__/assistant.runtime.test.ts b/apps/web/src/lib/server/domains/assistant/__tests__/assistant.runtime.test.ts index a5acf19ca5..4e7bad7398 100644 --- a/apps/web/src/lib/server/domains/assistant/__tests__/assistant.runtime.test.ts +++ b/apps/web/src/lib/server/domains/assistant/__tests__/assistant.runtime.test.ts @@ -437,15 +437,12 @@ describe('respondEligible (silence rule)', () => { describe('assembleCitations', () => { const ledger = new Map([ - [ - 'kb_article_1', - { type: 'article', id: 'kb_article_1', title: 'T1', url: '/hc/articles/g/a1' }, - ], + ['article_1', { type: 'article', id: 'article_1', title: 'T1', url: '/hc/articles/g/a1' }], ]) it('keeps only surfaced ids, enriched from the ledger', () => { - expect(assembleCitations([{ type: 'article', id: 'kb_article_1' }], ledger)).toEqual([ - { type: 'article', id: 'kb_article_1', title: 'T1', url: '/hc/articles/g/a1' }, + expect(assembleCitations([{ type: 'article', id: 'article_1' }], ledger)).toEqual([ + { type: 'article', id: 'article_1', title: 'T1', url: '/hc/articles/g/a1' }, ]) }) @@ -467,13 +464,13 @@ describe('assembleCitations', () => { expect( assembleCitations( [ - { type: 'article', id: 'kb_article_1' }, + { type: 'article', id: 'article_1' }, { type: 'article', id: 'kb_article_HALLUCINATED' }, - { type: 'article', id: 'kb_article_1' }, + { type: 'article', id: 'article_1' }, ], ledger ) - ).toEqual([{ type: 'article', id: 'kb_article_1', title: 'T1', url: '/hc/articles/g/a1' }]) + ).toEqual([{ type: 'article', id: 'article_1', title: 'T1', url: '/hc/articles/g/a1' }]) }) it('round-trips a post citation the same way as an article one (post grounding source)', () => { @@ -487,13 +484,13 @@ describe('assembleCitations', () => { expect( assembleCitations( [ - { type: 'article', id: 'kb_article_1' }, + { type: 'article', id: 'article_1' }, { type: 'post', id: 'post_1' }, ], postLedger ) ).toEqual([ - { type: 'article', id: 'kb_article_1', title: 'T1', url: '/hc/articles/g/a1' }, + { type: 'article', id: 'article_1', title: 'T1', url: '/hc/articles/g/a1' }, { type: 'post', id: 'post_1', title: 'Dark mode request', url: '/b/general/posts/post_1' }, ]) }) @@ -509,13 +506,13 @@ describe('assembleCitations', () => { expect( assembleCitations( [ - { type: 'article', id: 'kb_article_1' }, + { type: 'article', id: 'article_1' }, { type: 'snippet', id: 'assistant_snippet_1' }, ], snippetLedger ) ).toEqual([ - { type: 'article', id: 'kb_article_1', title: 'T1', url: '/hc/articles/g/a1' }, + { type: 'article', id: 'article_1', title: 'T1', url: '/hc/articles/g/a1' }, { type: 'snippet', id: 'assistant_snippet_1', title: 'Refund window', url: '' }, ]) }) @@ -531,19 +528,19 @@ describe('assembleCitations', () => { expect( assembleCitations( [ - { type: 'article', id: 'kb_article_1' }, + { type: 'article', id: 'article_1' }, { type: 'summary', id: 'conversation_1' }, ], summaryLedger ) ).toEqual([ - { type: 'article', id: 'kb_article_1', title: 'T1', url: '/hc/articles/g/a1' }, + { type: 'article', id: 'article_1', title: 'T1', url: '/hc/articles/g/a1' }, { type: 'summary', id: 'conversation_1', title: 'Past conversation', url: '' }, ]) }) it('drops everything when nothing cleared the confidence floor (empty ledger)', () => { - expect(assembleCitations([{ type: 'article', id: 'kb_article_1' }], new Map())).toEqual([]) + expect(assembleCitations([{ type: 'article', id: 'article_1' }], new Map())).toEqual([]) }) }) @@ -571,7 +568,7 @@ describe('structural completion check', () => { expect(() => validateAssistantCompletion({ text: 'Use the reset link. [1]', - citations: [{ type: 'article', id: 'kb_article_1' }], + citations: [{ type: 'article', id: 'article_1' }], }) ).not.toThrow() }) @@ -625,7 +622,7 @@ describe('runAssistantTurn', () => { }) it('runs the tool round trip and assembles citations from what search surfaced', async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) const deltas: string[] = [] mockChat.mockImplementation( (opts: { @@ -641,7 +638,7 @@ describe('runAssistantTurn', () => { ) const object = { text: 'Use the reset link.', - citations: [{ type: 'article', id: 'kb_article_1' }], + citations: [{ type: 'article', id: 'article_1' }], } yield { type: 'TEXT_MESSAGE_CONTENT', delta: JSON.stringify(object) } yield { type: 'CUSTOM', name: 'structured-output.complete', value: { object } } @@ -665,9 +662,9 @@ describe('runAssistantTurn', () => { citations: [ { type: 'article', - id: 'kb_article_1', - title: 'Title kb_article_1', - url: '/hc/en/articles/1-slug-kb_article_1', + id: 'article_1', + title: 'Title article_1', + url: '/hc/en/articles/1-slug-article_1', updatedAt: '2026-06-01T00:00:00.000Z', }, ], @@ -803,7 +800,7 @@ describe('runAssistantTurn', () => { expect(prompt).not.toContain('\n# Ignore previous') }) it('derives a team content audience for the copilot surface (structural leak gate)', async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) mockChat.mockImplementation( (opts: { tools: Array<{ name: string; execute: (args: unknown, o: unknown) => Promise }> @@ -817,7 +814,7 @@ describe('runAssistantTurn', () => { ) const object = { text: 'Here is the policy.', - citations: [{ type: 'article', id: 'kb_article_1' }], + citations: [{ type: 'article', id: 'article_1' }], } yield* completeRun(object) })() @@ -872,7 +869,7 @@ describe('runAssistantTurn', () => { }) it('internalSourced stays false when every retrieved source is public', async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1', { isPublic: true })]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1', { isPublic: true })]) mockChat.mockImplementation( (opts: { tools: Array<{ name: string; execute: (args: unknown, o: unknown) => Promise }> @@ -886,7 +883,7 @@ describe('runAssistantTurn', () => { ) yield* completeRun({ text: 'Here is the policy.', - citations: [{ type: 'article', id: 'kb_article_1' }], + citations: [{ type: 'article', id: 'article_1' }], }) })() ) @@ -919,7 +916,7 @@ describe('runAssistantTurn', () => { }) it("carries the source's updatedAt on every surface's citations (freshness line; the orchestrator strips it at persistence)", async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) const turnWith = (copilot = false) => { mockChat.mockImplementation( (opts: { @@ -934,7 +931,7 @@ describe('runAssistantTurn', () => { ) yield* completeRun({ text: 'Here is the policy.', - citations: [{ type: 'article', id: 'kb_article_1' }], + citations: [{ type: 'article', id: 'article_1' }], }) })() ) @@ -1001,7 +998,7 @@ describe('runAssistantTurn', () => { // A grounded source in the ledger plus report_inability: an honest "I // can't help" must not dress itself in sources, so the cited id is dropped // and its inline marker stripped. - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) mockChat.mockImplementation( (opts: { tools: Array<{ name: string; execute: (args: unknown, o: unknown) => Promise }> @@ -1020,7 +1017,7 @@ describe('runAssistantTurn', () => { ) const object = { text: 'The docs only cover part of this. [1] I cannot answer fully.', - citations: [{ type: 'article', id: 'kb_article_1' }], + citations: [{ type: 'article', id: 'article_1' }], } yield { type: 'CUSTOM', name: 'structured-output.complete', value: { object } } yield { type: 'RUN_FINISHED', usage: undefined } @@ -1281,7 +1278,7 @@ describe('runAssistantTurn', () => { }) it('logs answerKind "answered" in the usage-log metadata for a normal grounded reply', async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) mockChat.mockImplementation( (opts: { tools: Array<{ name: string; execute: (args: unknown, o: unknown) => Promise }> @@ -1295,7 +1292,7 @@ describe('runAssistantTurn', () => { ) const object = { text: 'Use the reset link.', - citations: [{ type: 'article', id: 'kb_article_1' }], + citations: [{ type: 'article', id: 'article_1' }], } yield { type: 'TEXT_MESSAGE_CONTENT', delta: JSON.stringify(object) } yield { type: 'CUSTOM', name: 'structured-output.complete', value: { object } } @@ -1319,11 +1316,11 @@ describe('runAssistantTurn', () => { citationCandidates: 1, completionDisposition: 'answer', }) - expect(lastLoggedMetadata?.citedSources).toEqual([{ type: 'article', id: 'kb_article_1' }]) + expect(lastLoggedMetadata?.citedSources).toEqual([{ type: 'article', id: 'article_1' }]) }) it('logs citedSources with one entry per distinct source actually cited, dropping a hallucinated id', async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1'), makeKbArticle('kb_article_2')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1'), makeKbArticle('article_2')]) mockChat.mockImplementation( (opts: { tools: Array<{ name: string; execute: (args: unknown, o: unknown) => Promise }> @@ -1338,11 +1335,11 @@ describe('runAssistantTurn', () => { const object = { text: 'Use the reset link. [1][2][3]', citations: [ - { type: 'article', id: 'kb_article_1' }, + { type: 'article', id: 'article_1' }, // A duplicate reference to the same source collapses to one entry. - { type: 'article', id: 'kb_article_1' }, + { type: 'article', id: 'article_1' }, // A hallucinated id the ledger never surfaced is dropped. - { type: 'article', id: 'kb_article_missing' }, + { type: 'article', id: 'article_missing' }, ], } yield { type: 'TEXT_MESSAGE_CONTENT', delta: JSON.stringify(object) } @@ -1356,7 +1353,7 @@ describe('runAssistantTurn', () => { messages: customerAsks('how do I reset my password?'), }) - expect(lastLoggedMetadata?.citedSources).toEqual([{ type: 'article', id: 'kb_article_1' }]) + expect(lastLoggedMetadata?.citedSources).toEqual([{ type: 'article', id: 'article_1' }]) }) it('omits citedSources from the logged metadata when nothing was cited', async () => { diff --git a/apps/web/src/lib/server/domains/assistant/__tests__/assistant.tools.test.ts b/apps/web/src/lib/server/domains/assistant/__tests__/assistant.tools.test.ts index 497203bdb4..3e7ecaf287 100644 --- a/apps/web/src/lib/server/domains/assistant/__tests__/assistant.tools.test.ts +++ b/apps/web/src/lib/server/domains/assistant/__tests__/assistant.tools.test.ts @@ -189,7 +189,7 @@ beforeEach(() => { describe('search', () => { it('retrieves audience-scoped, records sources in the ledger, and allowlists output', async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1', { content: 'X'.repeat(5000) })]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1', { content: 'X'.repeat(5000) })]) const c = ctx({ audience: 'team' }) const search = await findTool(c, 'search') @@ -200,18 +200,18 @@ describe('search', () => { expect(mockRetrieve).toHaveBeenCalledWith('billing', { audience: 'team' }) expect(out.results).toHaveLength(1) expect(out.results[0]).toEqual({ - id: 'kb_article_1', + id: 'article_1', kind: 'article', - title: 'Title kb_article_1', - url: '/hc/en/articles/1-slug-kb_article_1', + title: 'Title article_1', + url: '/hc/en/articles/1-slug-article_1', snippet: expect.any(String), }) expect(out.results[0].snippet.length).toBeLessThanOrEqual(1200) - expect(c.ledger.sources.get('kb_article_1')).toEqual({ + expect(c.ledger.sources.get('article_1')).toEqual({ type: 'article', - id: 'kb_article_1', - title: 'Title kb_article_1', - url: '/hc/en/articles/1-slug-kb_article_1', + id: 'article_1', + title: 'Title article_1', + url: '/hc/en/articles/1-slug-article_1', updatedAt: '2026-06-01T00:00:00.000Z', }) }) @@ -226,7 +226,7 @@ describe('search', () => { }) it('frames a non-empty result with the shared content-not-instructions note (retrieval is the fourth guard surface)', async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) const c = ctx() const search = await findTool(c, 'search') @@ -250,17 +250,17 @@ describe('search', () => { }) it("records each surfaced source's updatedAt on the ledgered citation itself (stripped only at persistence)", async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) const c = ctx() const search = await findTool(c, 'search') await search.execute({ query: 'billing' }, toolCtx(c)) - expect(c.ledger.sources.get('kb_article_1')?.updatedAt).toBe('2026-06-01T00:00:00.000Z') + expect(c.ledger.sources.get('article_1')?.updatedAt).toBe('2026-06-01T00:00:00.000Z') }) it('ends exploration past the per-turn search budget with an answer-now note', async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) const c = ctx() const search = await findTool(c, 'search') for (let i = 0; i < 3; i++) await search.execute({ query: `q${i}` }, toolCtx(c)) @@ -273,11 +273,11 @@ describe('search', () => { expect(mockRetrieve).toHaveBeenCalledTimes(3) expect(out.results).toEqual([]) expect(out.note).toMatch(/answer/i) - expect(c.ledger.sources.has('kb_article_1')).toBe(true) + expect(c.ledger.sources.has('article_1')).toBe(true) }) it("forwards the context's sourceTypes into retrieveKnowledge, narrowing away the knowledge base", async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) // sourceTypes excludes 'article': the only registered source (flags off) // gets filtered out entirely, so retrieveKbArticles is never called. const c = ctx({ sourceTypes: ['post'] }) @@ -700,7 +700,7 @@ describe('assembleAssistantToolset: sandbox simulate mode', () => { }) it('still executes a read tool normally in simulate mode', async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) const c = ctx({ conversationId: null, simulate: true }) const tool = await findTool(c, 'search') diff --git a/apps/web/src/lib/server/domains/assistant/__tests__/retrieval-sources.test.ts b/apps/web/src/lib/server/domains/assistant/__tests__/retrieval-sources.test.ts index c7126ed404..6a396428f2 100644 --- a/apps/web/src/lib/server/domains/assistant/__tests__/retrieval-sources.test.ts +++ b/apps/web/src/lib/server/domains/assistant/__tests__/retrieval-sources.test.ts @@ -100,7 +100,7 @@ beforeEach(() => { describe('kbKnowledgeSource', () => { it('maps a retrieved article onto a RetrievedItem with an article citation', async () => { mockRetrieveKbArticles.mockResolvedValue([ - makeKbArticle('kb_article_1', { content: 'X'.repeat(5000), score: 0.87 }), + makeKbArticle('article_1', { content: 'X'.repeat(5000), score: 0.87 }), ]) const items = await kbKnowledgeSource.retrieve('reset password', 'public', { @@ -110,9 +110,9 @@ describe('kbKnowledgeSource', () => { expect(mockRetrieveKbArticles).toHaveBeenCalledWith('reset password', { audience: 'public' }) expect(items).toHaveLength(1) expect(items[0]).toEqual({ - id: 'kb_article_1', + id: 'article_1', sourceType: 'article', - title: 'Title kb_article_1', + title: 'Title article_1', excerpt: 'X'.repeat(KNOWLEDGE_SNIPPET_CHARS), score: 0.87, // The row's own updated_at, ISO-encoded for the copilot freshness line — @@ -121,9 +121,9 @@ describe('kbKnowledgeSource', () => { updatedAt: '2026-06-01T00:00:00.000Z', citation: { type: 'article', - id: 'kb_article_1', - title: 'Title kb_article_1', - url: '/hc/en/articles/1-slug-kb_article_1', + id: 'article_1', + title: 'Title article_1', + url: '/hc/en/articles/1-slug-article_1', }, }) }) @@ -262,7 +262,7 @@ describe('resolveKnowledgeSources', () => { describe('retrieveKnowledge', () => { it('consults only the knowledge base when no enabled set is passed (KB-only default)', async () => { - mockRetrieveKbArticles.mockResolvedValue([makeKbArticle('kb_article_1', { score: 0.9 })]) + mockRetrieveKbArticles.mockResolvedValue([makeKbArticle('article_1', { score: 0.9 })]) const items = await retrieveKnowledge('q', 'public') @@ -404,7 +404,7 @@ describe('retrieveKnowledge', () => { }) it('sourceTypes undefined consults every registered source (default, unchanged)', async () => { - mockRetrieveKbArticles.mockResolvedValue([makeKbArticle('kb_article_1', { score: 0.5 })]) + mockRetrieveKbArticles.mockResolvedValue([makeKbArticle('article_1', { score: 0.5 })]) mockPostsRetrieve.mockResolvedValue([]) mockSnippetsRetrieve.mockResolvedValue([]) mockConversationSummariesRetrieve.mockResolvedValue([]) @@ -420,7 +420,7 @@ describe('retrieveKnowledge', () => { }) it('sourceTypes narrows to the given subset, skipping every other registered source', async () => { - mockRetrieveKbArticles.mockResolvedValue([makeKbArticle('kb_article_1', { score: 0.5 })]) + mockRetrieveKbArticles.mockResolvedValue([makeKbArticle('article_1', { score: 0.5 })]) mockSnippetsRetrieve.mockResolvedValue([ { id: 'assistant_snippet_1', @@ -447,7 +447,7 @@ describe('retrieveKnowledge', () => { it('cannot re-enable an unregistered source: sourceTypes only narrows what the snapshot already registered', async () => { // Only the knowledge base is enabled, even though the request asks for // posts too — narrowing can drop, never add. - mockRetrieveKbArticles.mockResolvedValue([makeKbArticle('kb_article_1', { score: 0.5 })]) + mockRetrieveKbArticles.mockResolvedValue([makeKbArticle('article_1', { score: 0.5 })]) const items = await retrieveKnowledge('q', 'public', { enabledSources: new Set(['article']), @@ -455,7 +455,7 @@ describe('retrieveKnowledge', () => { }) expect(mockPostsRetrieve).not.toHaveBeenCalled() - expect(items.map((i) => i.id)).toEqual(['kb_article_1']) + expect(items.map((i) => i.id)).toEqual(['article_1']) }) it('forwards customerPrincipalId and conversationId to every source (only the summaries source reads them)', async () => { diff --git a/apps/web/src/lib/server/domains/assistant/__tests__/retrieval.test.ts b/apps/web/src/lib/server/domains/assistant/__tests__/retrieval.test.ts index a159aa499a..a5ff199c7c 100644 --- a/apps/web/src/lib/server/domains/assistant/__tests__/retrieval.test.ts +++ b/apps/web/src/lib/server/domains/assistant/__tests__/retrieval.test.ts @@ -83,16 +83,16 @@ beforeEach(() => { describe('retrieveKbArticles', () => { it('uses the semantic path when a query embedding is available', async () => { mockGenerateKbEmbedding.mockResolvedValue([0.1, 0.2, 0.3]) - mockLimit.mockResolvedValue([row('kb_article_1')]) + mockLimit.mockResolvedValue([row('article_1')]) const result = await retrieveKbArticles('how do I invite teammates') expect(mockGenerateKbEmbedding).toHaveBeenCalledOnce() expect(result).toHaveLength(1) expect(result[0]).toMatchObject({ - id: 'kb_article_1', - slug: 'slug-kb_article_1', - title: 'Title kb_article_1', + id: 'article_1', + slug: 'slug-article_1', + title: 'Title article_1', categorySlug: 'general', categoryName: 'General', }) @@ -101,12 +101,12 @@ describe('retrieveKbArticles', () => { it('falls back to keyword retrieval when embeddings are unavailable', async () => { mockGenerateKbEmbedding.mockResolvedValue(null) - mockLimit.mockResolvedValue([row('kb_article_2')]) + mockLimit.mockResolvedValue([row('article_2')]) const result = await retrieveKbArticles('billing') expect(result).toHaveLength(1) - expect(result[0].id).toBe('kb_article_2') + expect(result[0].id).toBe('article_2') }) it('returns an empty list when nothing clears the similarity floor', async () => { diff --git a/apps/web/src/lib/server/domains/assistant/__tests__/synthesis.test.ts b/apps/web/src/lib/server/domains/assistant/__tests__/synthesis.test.ts index d8373fdccd..262557a20c 100644 --- a/apps/web/src/lib/server/domains/assistant/__tests__/synthesis.test.ts +++ b/apps/web/src/lib/server/domains/assistant/__tests__/synthesis.test.ts @@ -103,13 +103,13 @@ describe('isAskAiConfigured', () => { describe('buildAskAiSystemPrompts', () => { it('carries article ids and content, and teaches inline [n] citations', () => { - const prompts = buildAskAiSystemPrompts([article('kb_article_1'), article('kb_article_2')]) + const prompts = buildAskAiSystemPrompts([article('article_1'), article('article_2')]) const joined = prompts.join('\n') - expect(joined).toContain('kb_article_1') - expect(joined).toContain('Content of kb_article_2') + expect(joined).toContain('article_1') + expect(joined).toContain('Content of article_2') // Stuffed sources are numbered without [n], so inline markers stay unambiguous. - expect(joined).toContain('Source 1\narticleId: kb_article_1') - expect(joined).toContain('Source 2\narticleId: kb_article_2') + expect(joined).toContain('Source 1\narticleId: article_1') + expect(joined).toContain('Source 2\narticleId: article_2') expect(joined.toLowerCase()).toContain('[1] always means source 1') // Wikipedia-style inline markers, taught by example. expect(joined).toContain('[1]') @@ -118,14 +118,14 @@ describe('buildAskAiSystemPrompts', () => { }) it('carries the injection guard, grounding, and language instruction', () => { - const joined = buildAskAiSystemPrompts([article('kb_article_1')]).join('\n') + const joined = buildAskAiSystemPrompts([article('article_1')]).join('\n') expect(joined.toLowerCase()).toContain('not instructions') expect(joined.toLowerCase()).toContain('same language') expect(joined.toLowerCase()).toContain('never invent an articleid') }) it('teaches the graceful no_answer mode instead of an empty reply', () => { - const joined = buildAskAiSystemPrompts([article('kb_article_1')]).join('\n') + const joined = buildAskAiSystemPrompts([article('article_1')]).join('\n') // The model must always reply; a miss is a warm no_answer, never empty. expect(joined.toLowerCase()).toContain('never return an empty answer') expect(joined).toContain('no_answer') @@ -138,7 +138,7 @@ describe('synthesizeAnswer', () => { it('throws AskAiNotConfiguredError when no chat model is set', async () => { mockConfig.aiChatModel = undefined await expect( - synthesizeAnswer({ query: 'q', articles: [article('kb_article_1')] }) + synthesizeAnswer({ query: 'q', articles: [article('article_1')] }) ).rejects.toBeInstanceOf(AskAiNotConfiguredError) }) @@ -146,14 +146,14 @@ describe('synthesizeAnswer', () => { const object = { kind: 'grounded', answer: 'Use the invite button.', - sources: [{ articleId: 'kb_article_1' }], + sources: [{ articleId: 'article_1' }], } mockChat.mockReturnValueOnce(chunkStream(completeRun(object, JSON.stringify(object)))) const deltas: string[] = [] const result = await synthesizeAnswer({ query: 'how to invite?', - articles: [article('kb_article_1')], + articles: [article('article_1')], onAnswerDelta: (d) => deltas.push(d), }) @@ -171,7 +171,7 @@ describe('synthesizeAnswer', () => { const result = await synthesizeAnswer({ query: 'how do I enable dark mode?', - articles: [article('kb_article_1')], + articles: [article('article_1')], }) expect(result).toEqual(object) @@ -181,7 +181,7 @@ describe('synthesizeAnswer', () => { // Provider streamed valid-but-fenced JSON and never emitted the structured // completion event: jsonrepair should recover it rather than failing. const raw = - '```json\n{"kind":"grounded","answer":"Click save [1]","sources":[{"articleId":"kb_article_1"}]}\n```' + '```json\n{"kind":"grounded","answer":"Click save [1]","sources":[{"articleId":"article_1"}]}\n```' mockChat.mockReturnValueOnce( chunkStream([ { type: 'TEXT_MESSAGE_CONTENT', delta: raw }, @@ -189,11 +189,11 @@ describe('synthesizeAnswer', () => { ]) ) - const result = await synthesizeAnswer({ query: 'q', articles: [article('kb_article_1')] }) + const result = await synthesizeAnswer({ query: 'q', articles: [article('article_1')] }) expect(result).toEqual({ kind: 'grounded', answer: 'Click save [1]', - sources: [{ articleId: 'kb_article_1' }], + sources: [{ articleId: 'article_1' }], }) expect(mockChat).toHaveBeenCalledTimes(1) }) @@ -203,20 +203,20 @@ describe('synthesizeAnswer', () => { kind: 'grounded', answer: 'Answer.', sources: [ - { articleId: 'kb_article_1' }, + { articleId: 'article_1' }, { articleId: 'kb_article_HALLUCINATED' }, - { articleId: 'kb_article_1' }, + { articleId: 'article_1' }, ], } mockChat.mockReturnValueOnce(chunkStream(completeRun(object, JSON.stringify(object)))) const result = await synthesizeAnswer({ query: 'q', - articles: [article('kb_article_1'), article('kb_article_2')], + articles: [article('article_1'), article('article_2')], }) expect(result.kind).toBe('grounded') - expect(result.sources).toEqual([{ articleId: 'kb_article_1' }]) + expect(result.sources).toEqual([{ articleId: 'article_1' }]) }) it('demotes a grounded answer with no surviving citations to a safe miss', async () => { @@ -229,7 +229,7 @@ describe('synthesizeAnswer', () => { } mockChat.mockReturnValueOnce(chunkStream(completeRun(object, JSON.stringify(object)))) - const result = await synthesizeAnswer({ query: 'q', articles: [article('kb_article_1')] }) + const result = await synthesizeAnswer({ query: 'q', articles: [article('article_1')] }) expect(result).toEqual({ kind: 'no_answer', answer: ASK_AI_MISS_FALLBACK, sources: [] }) }) @@ -263,13 +263,13 @@ describe('synthesizeAnswer', () => { const result = await synthesizeAnswer({ query: 'q', - articles: [article('kb_article_1'), article('kb_article_2')], + articles: [article('article_1'), article('article_2')], }) expect(result).toEqual({ kind: 'grounded', answer: 'Click save [1].', - sources: [{ articleId: 'kb_article_2' }], + sources: [{ articleId: 'article_2' }], }) }) @@ -333,11 +333,11 @@ describe('synthesizeAnswer', () => { const object = { kind: 'grounded', answer: 'A. [1]', - sources: [{ articleId: 'kb_article_1' }], + sources: [{ articleId: 'article_1' }], } mockChat.mockReturnValueOnce(chunkStream(completeRun(object, JSON.stringify(object)))) - await synthesizeAnswer({ query: 'my question', articles: [article('kb_article_1')] }) + await synthesizeAnswer({ query: 'my question', articles: [article('article_1')] }) const call = mockChat.mock.calls[0][0] as { messages: Array<{ role: string; content: string }> @@ -351,13 +351,13 @@ describe('synthesizeAnswer', () => { const object = { kind: 'grounded', answer: 'Second try.', - sources: [{ articleId: 'kb_article_1' }], + sources: [{ articleId: 'article_1' }], } mockChat .mockReturnValueOnce(chunkStream([{ type: 'RUN_FINISHED', usage: undefined }])) .mockReturnValueOnce(chunkStream(completeRun(object, JSON.stringify(object)))) - const result = await synthesizeAnswer({ query: 'q', articles: [article('kb_article_1')] }) + const result = await synthesizeAnswer({ query: 'q', articles: [article('article_1')] }) expect(result.answer).toBe('Second try.') expect(mockChat).toHaveBeenCalledTimes(2) }) @@ -368,7 +368,7 @@ describe('synthesizeAnswer', () => { .mockReturnValueOnce(chunkStream([{ type: 'RUN_FINISHED' }])) await expect( - synthesizeAnswer({ query: 'q', articles: [article('kb_article_1')] }) + synthesizeAnswer({ query: 'q', articles: [article('article_1')] }) ).rejects.toThrow() expect(mockChat).toHaveBeenCalledTimes(2) }) @@ -379,7 +379,7 @@ describe('synthesizeAnswer', () => { chunkStream([{ type: 'RUN_ERROR', message: 'provider exploded' }]) ) await expect( - synthesizeAnswer({ query: 'q', articles: [article('kb_article_1')] }) + synthesizeAnswer({ query: 'q', articles: [article('article_1')] }) ).rejects.toThrow(/provider exploded/) }) @@ -402,7 +402,7 @@ describe('synthesizeAnswer', () => { await synthesizeAnswer({ query: 'q', - articles: [article('kb_article_1')], + articles: [article('article_1')], signal: abort.signal, }) @@ -413,14 +413,14 @@ describe('synthesizeAnswer', () => { const object = { kind: 'no_answer', answer: 'A.', sources: [] } mockChat.mockReturnValueOnce(chunkStream(completeRun(object, JSON.stringify(object)))) - await synthesizeAnswer({ query: 'q', articles: [article('kb_article_1')] }) + await synthesizeAnswer({ query: 'q', articles: [article('article_1')] }) const [params] = mockWithUsageLogging.mock.calls[0] expect(params).toMatchObject({ pipelineStep: 'help_center_answers', callType: 'chat_completion', model: 'test-model', - metadata: { kbArticleIds: ['kb_article_1'] }, + metadata: { kbArticleIds: ['article_1'] }, }) }) }) diff --git a/apps/web/src/lib/server/domains/export/__tests__/entities.test.ts b/apps/web/src/lib/server/domains/export/__tests__/entities.test.ts index 007e4e5110..cc1a7d5f59 100644 --- a/apps/web/src/lib/server/domains/export/__tests__/entities.test.ts +++ b/apps/web/src/lib/server/domains/export/__tests__/entities.test.ts @@ -165,7 +165,7 @@ describe('conversationsExporter.serialize', () => { describe('kbArticlesExporter.serialize', () => { it('renders the category slug', () => { const line = kbArticlesExporter.serialize({ - id: 'kb_article_1', + id: 'article_1', category: { slug: 'getting-started' }, slug: 'install', title: 'Install the widget', @@ -177,7 +177,7 @@ describe('kbArticlesExporter.serialize', () => { notHelpfulCount: 1, createdAt: d('2026-07-06T00:00:00Z'), } as never) - expect(line).toContain('kb_article_1,"getting-started","install","Install the widget"') + expect(line).toContain('article_1,"getting-started","install","Install the widget"') }) }) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-feedback.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-feedback.service.test.ts index 74befd714c..fbe44da604 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-feedback.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-feedback.service.test.ts @@ -103,7 +103,7 @@ beforeEach(async () => { describe('recordArticleFeedback', () => { it('returns the id of the vote it inserted so an anonymous visitor can explain it', async () => { - const feedbackId = await recordArticleFeedback('kb_article_1' as KbArticleId, false) + const feedbackId = await recordArticleFeedback('article_1' as KbArticleId, false) expect(feedbackId).toMatch(/^kb_article_feedback_/) const [inserted] = insertValuesCalls[0] as [{ id: string; helpful: boolean }] @@ -114,14 +114,14 @@ describe('recordArticleFeedback', () => { it('returns the existing id when a known visitor repeats the same vote', async () => { mockFeedbackFindFirst.mockResolvedValue({ id: 'kb_article_feedback_1', - articleId: 'kb_article_1', + articleId: 'article_1', principalId: 'principal_1', helpful: false, reason: 'Missing the CLI flag', }) const feedbackId = await recordArticleFeedback( - 'kb_article_1' as KbArticleId, + 'article_1' as KbArticleId, false, 'principal_1' as PrincipalId ) @@ -133,13 +133,13 @@ describe('recordArticleFeedback', () => { it('clears the reason when a vote flips to helpful', async () => { mockFeedbackFindFirst.mockResolvedValue({ id: 'kb_article_feedback_1', - articleId: 'kb_article_1', + articleId: 'article_1', principalId: 'principal_1', helpful: false, reason: 'Missing the CLI flag', }) - await recordArticleFeedback('kb_article_1' as KbArticleId, true, 'principal_1' as PrincipalId) + await recordArticleFeedback('article_1' as KbArticleId, true, 'principal_1' as PrincipalId) expect(updateSetCalls[0]).toEqual([{ helpful: true, reason: null }]) }) @@ -214,7 +214,7 @@ describe('listArticleFeedbackReasons', () => { }, ]) - const reasons = await listArticleFeedbackReasons('kb_article_1' as KbArticleId) + const reasons = await listArticleFeedbackReasons('article_1' as KbArticleId) expect(reasons.map((r) => r.reason)).toEqual([ 'The screenshots are out of date', @@ -223,7 +223,7 @@ describe('listArticleFeedbackReasons', () => { }) it('caps the page size it asks the database for', async () => { - await listArticleFeedbackReasons('kb_article_1' as KbArticleId, 500) + await listArticleFeedbackReasons('article_1' as KbArticleId, 500) const [query] = mockFeedbackFindMany.mock.calls[0] as [{ limit: number }] expect(query.limit).toBe(100) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-performance.query.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-performance.query.test.ts index 0c451240d9..3698672d90 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-performance.query.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-performance.query.test.ts @@ -27,7 +27,7 @@ describe('listArticlePerformance', () => { it('marks a row published when publishedAt is set and draft otherwise', async () => { mockLimit.mockResolvedValue([ { - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'popular-article', title: 'Popular Article', publishedAt: new Date('2024-01-01'), @@ -37,7 +37,7 @@ describe('listArticlePerformance', () => { notHelpfulCount: 5, }, { - id: 'kb_article_2' as KbArticleId, + id: 'article_2' as KbArticleId, slug: 'draft-article', title: 'Draft Article', publishedAt: null, @@ -52,7 +52,7 @@ describe('listArticlePerformance', () => { expect(result).toEqual([ { - id: 'kb_article_1', + id: 'article_1', slug: 'popular-article', title: 'Popular Article', status: 'published', @@ -62,7 +62,7 @@ describe('listArticlePerformance', () => { notHelpfulCount: 5, }, { - id: 'kb_article_2', + id: 'article_2', slug: 'draft-article', title: 'Draft Article', status: 'draft', diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-search-parity.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-search-parity.test.ts index ee18519606..aa207aef6c 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-search-parity.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article-search-parity.test.ts @@ -78,8 +78,8 @@ beforeEach(() => { describe('listArticles hybrid search parity', () => { it('routes search through the ranked hybrid with team visibility', async () => { - mockSearchArticleIdsRanked.mockResolvedValue(['kb_article_1']) - mockArticleFindMany.mockResolvedValue([dbRow('kb_article_1')]) + mockSearchArticleIdsRanked.mockResolvedValue(['article_1']) + mockArticleFindMany.mockResolvedValue([dbRow('article_1')]) const result = await listArticles({ search: 'dark mode', status: 'all' }) @@ -90,32 +90,32 @@ describe('listArticles hybrid search parity', () => { status: 'all', limit: 50, }) - expect(result.items.map((i) => i.id)).toEqual(['kb_article_1']) + expect(result.items.map((i) => i.id)).toEqual(['article_1']) }) it('preserves rank order over db row order', async () => { - mockSearchArticleIdsRanked.mockResolvedValue(['kb_article_2', 'kb_article_1']) + mockSearchArticleIdsRanked.mockResolvedValue(['article_2', 'article_1']) // db returns rows in a different order than the ranking - mockArticleFindMany.mockResolvedValue([dbRow('kb_article_1'), dbRow('kb_article_2')]) + mockArticleFindMany.mockResolvedValue([dbRow('article_1'), dbRow('article_2')]) const result = await listArticles({ search: 'q' }) - expect(result.items.map((i) => i.id)).toEqual(['kb_article_2', 'kb_article_1']) + expect(result.items.map((i) => i.id)).toEqual(['article_2', 'article_1']) }) it('paginates by slicing the ranked pool after the cursor', async () => { - mockSearchArticleIdsRanked.mockResolvedValue(['kb_article_1', 'kb_article_2', 'kb_article_3']) - mockArticleFindMany.mockResolvedValue([dbRow('kb_article_2')]) + mockSearchArticleIdsRanked.mockResolvedValue(['article_1', 'article_2', 'article_3']) + mockArticleFindMany.mockResolvedValue([dbRow('article_2')]) - const result = await listArticles({ search: 'q', cursor: 'kb_article_1', limit: 1 }) + const result = await listArticles({ search: 'q', cursor: 'article_1', limit: 1 }) - expect(result.items.map((i) => i.id)).toEqual(['kb_article_2']) + expect(result.items.map((i) => i.id)).toEqual(['article_2']) expect(result.hasMore).toBe(true) - expect(result.nextCursor).toBe('kb_article_2') + expect(result.nextCursor).toBe('article_2') }) it('returns an empty page for an unknown cursor', async () => { - mockSearchArticleIdsRanked.mockResolvedValue(['kb_article_1']) - const result = await listArticles({ search: 'q', cursor: 'kb_article_gone' }) + mockSearchArticleIdsRanked.mockResolvedValue(['article_1']) + const result = await listArticles({ search: 'q', cursor: 'article_gone' }) expect(result.items).toEqual([]) expect(result.hasMore).toBe(false) }) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts index 496978fb57..9f63bba603 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.query.test.ts @@ -70,7 +70,7 @@ describe('listPublicArticlesForCategory', () => { const mockArticles = [ { - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'first-article', title: 'First Article', description: 'Desc 1', @@ -78,7 +78,7 @@ describe('listPublicArticlesForCategory', () => { publishedAt: new Date('2024-01-01'), }, { - id: 'kb_article_2' as KbArticleId, + id: 'article_2' as KbArticleId, slug: 'second-article', title: 'Second Article', description: null, @@ -126,7 +126,7 @@ describe('listArticles with showDeleted option', () => { const recentDeletedAt = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000) mockArticleFindMany.mockResolvedValue([ { - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'deleted-article', title: 'Deleted Article', description: null, @@ -154,7 +154,7 @@ describe('listArticles with showDeleted option', () => { it('returns live articles by default', async () => { mockArticleFindMany.mockResolvedValue([ { - id: 'kb_article_2' as KbArticleId, + id: 'article_2' as KbArticleId, slug: 'live-article', title: 'Live Article', description: null, @@ -207,7 +207,7 @@ describe('listArticles sort param', () => { it('returns articles with sort=newest (default)', async () => { const { asc: ascMock, desc: descMock } = await import('@/lib/server/db') - mockArticleFindMany.mockResolvedValue([makeArticle('kb_article_1', 'Article A')]) + mockArticleFindMany.mockResolvedValue([makeArticle('article_1', 'Article A')]) const result = await listArticles({ sort: 'newest' }) expect(result.items).toHaveLength(1) @@ -217,7 +217,7 @@ describe('listArticles sort param', () => { it('returns articles with sort=oldest using asc order', async () => { const { asc: ascMock } = await import('@/lib/server/db') - mockArticleFindMany.mockResolvedValue([makeArticle('kb_article_2', 'Article B')]) + mockArticleFindMany.mockResolvedValue([makeArticle('article_2', 'Article B')]) const result = await listArticles({ sort: 'oldest' }) expect(result.items).toHaveLength(1) @@ -226,7 +226,7 @@ describe('listArticles sort param', () => { it('defaults to newest when sort is not provided', async () => { const { desc: descMock } = await import('@/lib/server/db') - mockArticleFindMany.mockResolvedValue([makeArticle('kb_article_3', 'Article C')]) + mockArticleFindMany.mockResolvedValue([makeArticle('article_3', 'Article C')]) const result = await listArticles({}) expect(result.items).toHaveLength(1) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts index 076651f870..38efb6193a 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-article.service.test.ts @@ -17,7 +17,7 @@ function createUpdateChain() { }) chain.returning = vi.fn().mockResolvedValue([ { - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'test', title: 'Test', description: null, @@ -125,7 +125,7 @@ beforeEach(async () => { describe('getArticleById', () => { it('returns article with category when found', async () => { mockArticleFindFirst.mockResolvedValue({ - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'how-to-start', title: 'How to Start', content: 'Content here', @@ -152,7 +152,7 @@ describe('getArticleById', () => { avatarUrl: null, }) - const result = await getArticleById('kb_article_1' as KbArticleId) + const result = await getArticleById('article_1' as KbArticleId) expect(result.title).toBe('How to Start') expect(result.category.name).toBe('Getting Started') expect(result.author?.name).toBe('Test Author') @@ -161,7 +161,7 @@ describe('getArticleById', () => { it('throws NotFoundError when article does not exist', async () => { mockArticleFindFirst.mockResolvedValue(null) - await expect(getArticleById('kb_article_missing' as KbArticleId)).rejects.toMatchObject({ + await expect(getArticleById('article_missing' as KbArticleId)).rejects.toMatchObject({ code: 'ARTICLE_NOT_FOUND', }) }) @@ -177,7 +177,7 @@ describe('createArticle', () => { }) articleInsertChain.returning = vi.fn().mockResolvedValue([ { - id: 'kb_article_new1' as KbArticleId, + id: 'article_new1' as KbArticleId, slug: 'how-to-start', title: 'How to Start', content: 'Some content', @@ -291,7 +291,7 @@ describe('createArticle', () => { chain.values = vi.fn(() => chain) chain.returning = vi.fn().mockResolvedValue([ { - id: 'kb_article_new' as KbArticleId, + id: 'article_new' as KbArticleId, slug: 'title', title: 'Title', content: 'Content', @@ -328,7 +328,7 @@ describe('createArticle slug generation (#285)', () => { }) chain.returning = vi.fn().mockResolvedValue([ { - id: 'kb_article_new1' as KbArticleId, + id: 'article_new1' as KbArticleId, slug: 'placeholder', title: 'placeholder', content: 'Content', @@ -371,9 +371,7 @@ describe('createArticle slug generation (#285)', () => { }) it('appends a counter when the derived slug collides', async () => { - mockArticleFindFirst - .mockResolvedValueOnce({ id: 'kb_article_other' }) - .mockResolvedValueOnce(null) + mockArticleFindFirst.mockResolvedValueOnce({ id: 'article_other' }).mockResolvedValueOnce(null) await createArticle({ categoryId: 'kb_category_1', title: '反馈', content: 'c' }, author) expect(insertedSlug()).toBe('fan-kui-2') }) @@ -381,13 +379,13 @@ describe('createArticle slug generation (#285)', () => { describe('updateArticle slug generation (#285)', () => { it('falls back to a generic slug when an explicit empty slug is given', async () => { - await updateArticle('kb_article_1' as KbArticleId, { slug: '' }) + await updateArticle('article_1' as KbArticleId, { slug: '' }) expect((updateSetCalls[0][0] as Record).slug).toBe('article') }) it('keeps an explicit slug that only collides with the same article', async () => { - mockArticleFindFirst.mockResolvedValueOnce({ id: 'kb_article_1' }) - await updateArticle('kb_article_1' as KbArticleId, { slug: 'guide' }) + mockArticleFindFirst.mockResolvedValueOnce({ id: 'article_1' }) + await updateArticle('article_1' as KbArticleId, { slug: 'guide' }) expect((updateSetCalls[0][0] as Record).slug).toBe('guide') }) }) @@ -397,7 +395,7 @@ describe('publishArticle', () => { mockCategoryFindFirst.mockResolvedValue({ id: 'kb_category_1', slug: 'test', name: 'Test' }) mockPrincipalFindFirst.mockResolvedValue(null) - const result = await publishArticle('kb_article_1' as KbArticleId) + const result = await publishArticle('article_1' as KbArticleId) expect(result).toBeDefined() expect(updateSetCalls.length).toBeGreaterThan(0) }) @@ -408,7 +406,7 @@ describe('unpublishArticle', () => { mockCategoryFindFirst.mockResolvedValue({ id: 'kb_category_1', slug: 'test', name: 'Test' }) mockPrincipalFindFirst.mockResolvedValue(null) - const result = await unpublishArticle('kb_article_1' as KbArticleId) + const result = await unpublishArticle('article_1' as KbArticleId) expect(result).toBeDefined() expect(updateSetCalls.length).toBeGreaterThan(0) }) @@ -416,7 +414,7 @@ describe('unpublishArticle', () => { describe('deleteArticle', () => { it('soft deletes the article', async () => { - const result = await deleteArticle('kb_article_1' as KbArticleId) + const result = await deleteArticle('article_1' as KbArticleId) expect(result).toBeUndefined() }) @@ -428,7 +426,7 @@ describe('deleteArticle', () => { emptyChain.returning = vi.fn().mockResolvedValue([]) vi.mocked(db.update).mockReturnValueOnce(emptyChain as never) - await expect(deleteArticle('kb_article_missing' as KbArticleId)).rejects.toMatchObject({ + await expect(deleteArticle('article_missing' as KbArticleId)).rejects.toMatchObject({ code: 'ARTICLE_NOT_FOUND', }) }) @@ -444,7 +442,7 @@ describe('createArticle with position and description', () => { }) articleInsertChain.returning = vi.fn().mockResolvedValue([ { - id: 'kb_article_new1' as KbArticleId, + id: 'article_new1' as KbArticleId, slug: 'how-to-start', title: 'How to Start', description: 'A short intro', @@ -501,7 +499,7 @@ describe('updateArticle authorId validation', () => { type: 'user', }) await expect( - updateArticle('kb_article_1' as KbArticleId, {}, 'principal_portal' as PrincipalId) + updateArticle('article_1' as KbArticleId, {}, 'principal_portal' as PrincipalId) ).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }) }) @@ -512,13 +510,13 @@ describe('updateArticle authorId validation', () => { type: 'service', }) await expect( - updateArticle('kb_article_1' as KbArticleId, {}, 'principal_svc' as PrincipalId) + updateArticle('article_1' as KbArticleId, {}, 'principal_svc' as PrincipalId) ).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }) }) it('allows re-asserting a former-member as author when they already own the article', async () => { mockArticleFindFirst.mockResolvedValueOnce({ - id: 'kb_article_1', + id: 'article_1', principalId: 'principal_former', }) mockPrincipalFindFirst.mockResolvedValueOnce({ @@ -533,7 +531,7 @@ describe('updateArticle authorId validation', () => { chain.where = vi.fn(() => chain) chain.returning = vi.fn().mockResolvedValue([ { - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'test', title: 'Test', content: 'Content', @@ -557,7 +555,7 @@ describe('updateArticle authorId validation', () => { }) await expect( - updateArticle('kb_article_1' as KbArticleId, {}, 'principal_former' as PrincipalId) + updateArticle('article_1' as KbArticleId, {}, 'principal_former' as PrincipalId) ).resolves.toBeDefined() }) @@ -574,7 +572,7 @@ describe('updateArticle authorId validation', () => { chain.where = vi.fn(() => chain) chain.returning = vi.fn().mockResolvedValue([ { - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'test', title: 'Test', content: 'Content', @@ -598,7 +596,7 @@ describe('updateArticle authorId validation', () => { }) await expect( - updateArticle('kb_article_1' as KbArticleId, {}, 'principal_member' as PrincipalId) + updateArticle('article_1' as KbArticleId, {}, 'principal_member' as PrincipalId) ).resolves.toBeDefined() }) }) @@ -617,7 +615,7 @@ describe('updateArticle with position and description', () => { }) articleUpdateChain.returning = vi.fn().mockResolvedValue([ { - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'test', title: 'Test', description: 'Updated desc', @@ -639,7 +637,7 @@ describe('updateArticle with position and description', () => { mockCategoryFindFirst.mockResolvedValue({ id: 'kb_category_1', slug: 'test', name: 'Test' }) mockPrincipalFindFirst.mockResolvedValue(null) - await updateArticle('kb_article_1' as KbArticleId, { + await updateArticle('article_1' as KbArticleId, { position: 3, description: 'Updated desc', }) @@ -661,7 +659,7 @@ describe('restoreArticle', () => { chain.where = vi.fn().mockReturnValue(chain) chain.returning = vi.fn().mockResolvedValue([ { - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'how-to-start', title: 'How to Start', content: 'Some content', @@ -683,7 +681,7 @@ describe('restoreArticle', () => { it('restores a deleted article within the 30-day window', async () => { const recentDeletedAt = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000) mockArticleFindFirst.mockResolvedValue({ - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'how-to-start', title: 'How to Start', content: 'Some content', @@ -710,8 +708,8 @@ describe('restoreArticle', () => { }) mockPrincipalFindFirst.mockResolvedValue(null) - const result = await restoreArticle('kb_article_1' as KbArticleId) - expect(result.id).toBe('kb_article_1') + const result = await restoreArticle('article_1' as KbArticleId) + expect(result.id).toBe('article_1') expect(result.deletedAt).toBeNull() expect(setCallsCapture.length).toBeGreaterThan(0) const setArgs = setCallsCapture[0][0] as Record @@ -720,14 +718,14 @@ describe('restoreArticle', () => { it('throws NotFoundError for a non-existent article', async () => { mockArticleFindFirst.mockResolvedValue(null) - await expect(restoreArticle('kb_article_missing' as KbArticleId)).rejects.toMatchObject({ + await expect(restoreArticle('article_missing' as KbArticleId)).rejects.toMatchObject({ code: 'ARTICLE_NOT_FOUND', }) }) it('throws ValidationError when article is not deleted', async () => { mockArticleFindFirst.mockResolvedValue({ - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'live', title: 'Live Article', content: 'Content', @@ -742,7 +740,7 @@ describe('restoreArticle', () => { updatedAt: new Date('2024-01-01'), deletedAt: null, }) - await expect(restoreArticle('kb_article_1' as KbArticleId)).rejects.toMatchObject({ + await expect(restoreArticle('article_1' as KbArticleId)).rejects.toMatchObject({ code: 'VALIDATION_ERROR', }) }) @@ -750,7 +748,7 @@ describe('restoreArticle', () => { it('throws ValidationError when article is outside the 30-day restore window', async () => { const oldDeletedAt = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) mockArticleFindFirst.mockResolvedValue({ - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, slug: 'old', title: 'Old Article', content: 'Content', @@ -765,7 +763,7 @@ describe('restoreArticle', () => { updatedAt: new Date('2024-01-01'), deletedAt: oldDeletedAt, }) - await expect(restoreArticle('kb_article_1' as KbArticleId)).rejects.toMatchObject({ + await expect(restoreArticle('article_1' as KbArticleId)).rejects.toMatchObject({ code: 'RESTORE_EXPIRED', }) }) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-auto-translate.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-auto-translate.service.test.ts index 945ea8abff..01c172e68f 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-auto-translate.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-auto-translate.service.test.ts @@ -116,7 +116,7 @@ describe('translateArticleForLocale', () => { mockConfig.openaiApiKey = undefined mockGetChatModel.mockReturnValue(null) - await translateArticleForLocale('kb_article_1' as KbArticleId, 'de') + await translateArticleForLocale('article_1' as KbArticleId, 'de') expect(mockGetArticleById).not.toHaveBeenCalled() expect(mockUpsertArticleTranslation).not.toHaveBeenCalled() @@ -137,11 +137,11 @@ describe('translateArticleForLocale', () => { content: 'Kontaktieren Sie den Quackback-Support.', }) - await translateArticleForLocale('kb_article_1' as KbArticleId, 'de') + await translateArticleForLocale('article_1' as KbArticleId, 'de') expect(mockUpsertArticleTranslation).toHaveBeenCalledWith( expect.objectContaining({ - articleId: 'kb_article_1', + articleId: 'article_1', locale: 'de', title: 'Rückerstattungen', description: 'Wie man eine bekommt', @@ -159,7 +159,7 @@ describe('translateArticleForLocale', () => { mockChat.mockRejectedValue(new Error('response did not match schema')) await expect( - translateArticleForLocale('kb_article_1' as KbArticleId, 'de') + translateArticleForLocale('article_1' as KbArticleId, 'de') ).resolves.toBeUndefined() expect(mockUpsertArticleTranslation).not.toHaveBeenCalled() }) @@ -171,7 +171,7 @@ describe('translateArticleForLocale', () => { mockChat.mockResolvedValue({ title: '', content: '' }) await expect( - translateArticleForLocale('kb_article_1' as KbArticleId, 'de') + translateArticleForLocale('article_1' as KbArticleId, 'de') ).resolves.toBeUndefined() expect(mockUpsertArticleTranslation).not.toHaveBeenCalled() }) @@ -184,7 +184,7 @@ describe('queueAutoTranslateOnPublish', () => { locales: { additional: ['de', 'fr'] }, }) - await queueAutoTranslateOnPublish({ id: 'kb_article_1' } as never) + await queueAutoTranslateOnPublish({ id: 'article_1' } as never) expect(mockEnqueueHelpCenterTranslateJob).not.toHaveBeenCalled() }) @@ -195,7 +195,7 @@ describe('queueAutoTranslateOnPublish', () => { locales: { additional: [] }, }) - await queueAutoTranslateOnPublish({ id: 'kb_article_1' } as never) + await queueAutoTranslateOnPublish({ id: 'article_1' } as never) expect(mockEnqueueHelpCenterTranslateJob).not.toHaveBeenCalled() }) @@ -206,17 +206,17 @@ describe('queueAutoTranslateOnPublish', () => { locales: { additional: ['de', 'fr'] }, }) - await queueAutoTranslateOnPublish({ id: 'kb_article_1' } as never) + await queueAutoTranslateOnPublish({ id: 'article_1' } as never) expect(mockEnqueueHelpCenterTranslateJob).toHaveBeenCalledTimes(2) expect(mockEnqueueHelpCenterTranslateJob).toHaveBeenCalledWith({ type: 'translate-article', - articleId: 'kb_article_1', + articleId: 'article_1', locale: 'de', }) expect(mockEnqueueHelpCenterTranslateJob).toHaveBeenCalledWith({ type: 'translate-article', - articleId: 'kb_article_1', + articleId: 'article_1', locale: 'fr', }) }) @@ -224,8 +224,6 @@ describe('queueAutoTranslateOnPublish', () => { it('swallows enqueue errors rather than throwing (never blocks publish)', async () => { mockGetHelpCenterConfig.mockRejectedValue(new Error('settings unavailable')) - await expect( - queueAutoTranslateOnPublish({ id: 'kb_article_1' } as never) - ).resolves.toBeUndefined() + await expect(queueAutoTranslateOnPublish({ id: 'article_1' } as never)).resolves.toBeUndefined() }) }) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-embedding-logging.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-embedding-logging.test.ts index 10e1e5136f..9f4379631c 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-embedding-logging.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-embedding-logging.test.ts @@ -52,7 +52,7 @@ describe('generateKbEmbedding usage logging', () => { it('routes the embedding call through withUsageLogging with the given context', async () => { const result = await generateKbEmbedding('some text', { pipelineStep: 'kb_article_embedding', - metadata: { kbArticleId: 'kb_article_1' }, + metadata: { kbArticleId: 'article_1' }, }) expect(result).toEqual([0.1, 0.2]) @@ -62,7 +62,7 @@ describe('generateKbEmbedding usage logging', () => { pipelineStep: 'kb_article_embedding', callType: 'embedding', model: 'text-embedding-3-small', - metadata: { kbArticleId: 'kb_article_1' }, + metadata: { kbArticleId: 'article_1' }, }) }) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-locale.query.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-locale.query.test.ts index a9cb899305..4630064886 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-locale.query.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-locale.query.test.ts @@ -25,7 +25,8 @@ vi.mock('../help-center.article.service', () => ({ })) vi.mock('../help-center-translations.service', () => ({ - getPublishedArticleTranslation: (...args: unknown[]) => mockGetPublishedArticleTranslation(...args), + getPublishedArticleTranslation: (...args: unknown[]) => + mockGetPublishedArticleTranslation(...args), getCategoryTranslation: (...args: unknown[]) => mockGetCategoryTranslation(...args), })) @@ -49,7 +50,12 @@ vi.mock('@/lib/server/db', () => ({ isNull: (...args: unknown[]) => ({ op: 'isNull', args }), isNotNull: (...args: unknown[]) => ({ op: 'isNotNull', args }), count: () => ({ op: 'count' }), - helpCenterArticles: { categoryId: 'category_id', deletedAt: 'deleted_at', publishedAt: 'published_at', id: 'id' }, + helpCenterArticles: { + categoryId: 'category_id', + deletedAt: 'deleted_at', + publishedAt: 'published_at', + id: 'id', + }, helpCenterArticleTranslations: { articleId: 'article_id', locale: 'locale', status: 'status' }, helpCenterCategoryTranslations: { categoryId: 'category_id', locale: 'locale' }, })) @@ -121,9 +127,7 @@ describe('listPublicCategoriesForLocale', () => { }) const result = await listPublicCategoriesForLocale('de') - expect(result).toEqual([ - { id: 'kb_category_1', name: 'Abrechnung', description: 'DE desc' }, - ]) + expect(result).toEqual([{ id: 'kb_category_1', name: 'Abrechnung', description: 'DE desc' }]) }) }) @@ -158,7 +162,7 @@ describe('getPublicCategoryBySlugForLocale', () => { describe('listPublicArticlesForCategoryLocale', () => { it('returns the default-locale list unchanged for the default locale', async () => { - const articles = [{ id: 'kb_article_1', title: 'Invoices' }] + const articles = [{ id: 'article_1', title: 'Invoices' }] mockListPublicArticlesForCategory.mockResolvedValue(articles) const result = await listPublicArticlesForCategoryLocale('kb_category_1', 'en') @@ -167,27 +171,27 @@ describe('listPublicArticlesForCategoryLocale', () => { it('drops articles with no published translation and overlays the rest', async () => { mockListPublicArticlesForCategory.mockResolvedValue([ - { id: 'kb_article_1', title: 'Invoices', description: 'EN' }, - { id: 'kb_article_2', title: 'Refunds', description: 'EN' }, + { id: 'article_1', title: 'Invoices', description: 'EN' }, + { id: 'article_2', title: 'Refunds', description: 'EN' }, ]) mockArticleTranslationFindMany.mockResolvedValue([ - { articleId: 'kb_article_1', title: 'Rechnungen', description: 'DE' }, + { articleId: 'article_1', title: 'Rechnungen', description: 'DE' }, ]) const result = await listPublicArticlesForCategoryLocale('kb_category_1', 'de') - expect(result).toEqual([{ id: 'kb_article_1', title: 'Rechnungen', description: 'DE' }]) + expect(result).toEqual([{ id: 'article_1', title: 'Rechnungen', description: 'DE' }]) }) }) describe('getPublicArticleBySlugForLocale', () => { it('returns the base article for the default locale', async () => { - mockGetPublicArticleBySlug.mockResolvedValue({ id: 'kb_article_1', title: 'Invoices' }) + mockGetPublicArticleBySlug.mockResolvedValue({ id: 'article_1', title: 'Invoices' }) const result = await getPublicArticleBySlugForLocale('invoices', 'en') - expect(result).toEqual({ id: 'kb_article_1', title: 'Invoices' }) + expect(result).toEqual({ id: 'article_1', title: 'Invoices' }) }) it('throws when the article has no published translation in that locale', async () => { - mockGetPublicArticleBySlug.mockResolvedValue({ id: 'kb_article_1', title: 'Invoices' }) + mockGetPublicArticleBySlug.mockResolvedValue({ id: 'article_1', title: 'Invoices' }) mockGetPublishedArticleTranslation.mockResolvedValue(null) await expect(getPublicArticleBySlugForLocale('invoices', 'de')).rejects.toThrow(/translation/i) @@ -195,7 +199,7 @@ describe('getPublicArticleBySlugForLocale', () => { it('overlays translated content, falling back to base contentJson when the translation has none', async () => { mockGetPublicArticleBySlug.mockResolvedValue({ - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, title: 'Invoices', description: 'EN', content: 'en content', diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts index 147362bfe4..dddc232910 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts @@ -19,7 +19,7 @@ function createInsertChain() { id: 'hc_redirect_rule_new1' as HcRedirectRuleId, path: '/old-slug', targetType: 'article', - targetId: 'kb_article_1', + targetId: 'article_1', createdAt: new Date('2026-01-01'), }, ]) @@ -78,13 +78,13 @@ describe('createRedirectRule', () => { const rule = await createRedirectRule({ path: 'old-slug', targetType: 'article', - targetId: 'kb_article_1' as KbArticleId, + targetId: 'article_1' as KbArticleId, }) expect(insertValuesCalls[0][0]).toMatchObject({ path: '/old-slug', targetType: 'article', - targetId: 'kb_article_1', + targetId: 'article_1', }) expect(rule.targetLabel).toBe('Getting started') }) @@ -99,7 +99,7 @@ describe('createRedirectRule', () => { await createRedirectRule({ path: 'foo//bar/', targetType: 'article', - targetId: 'kb_article_1' as KbArticleId, + targetId: 'article_1' as KbArticleId, }) expect(insertValuesCalls[0][0]).toMatchObject({ path: '/foo/bar' }) @@ -116,7 +116,7 @@ describe('createRedirectRule', () => { createRedirectRule({ path: '/foo', targetType: 'article', - targetId: 'kb_article_1' as KbArticleId, + targetId: 'article_1' as KbArticleId, }) ).rejects.toThrow(/published/i) expect(insertValuesCalls).toHaveLength(0) @@ -129,7 +129,7 @@ describe('createRedirectRule', () => { createRedirectRule({ path: '/foo', targetType: 'article', - targetId: 'kb_article_missing' as KbArticleId, + targetId: 'article_missing' as KbArticleId, }) ).rejects.toThrow() }) @@ -163,7 +163,7 @@ describe('createRedirectRule', () => { createRedirectRule({ path: '/foo', targetType: 'article', - targetId: 'kb_article_1' as KbArticleId, + targetId: 'article_1' as KbArticleId, }) ).rejects.toThrow(/already exists/i) }) @@ -177,7 +177,7 @@ describe('listRedirectRules', () => { id: 'hc_redirect_rule_1' as HcRedirectRuleId, path: '/old', targetType: 'article', - targetId: 'kb_article_1', + targetId: 'article_1', createdAt: new Date('2026-01-01'), }, ]), @@ -197,7 +197,7 @@ describe('deleteRedirectRule / deleteRedirectRulesForTarget', () => { }) it('deletes every rule pointing at a target', async () => { - await deleteRedirectRulesForTarget('article', 'kb_article_1') + await deleteRedirectRulesForTarget('article', 'article_1') expect(mockDeleteWhere).toHaveBeenCalled() }) }) @@ -211,7 +211,7 @@ describe('resolveRedirectRule', () => { it('resolves an article rule to its canonical /hc path', async () => { mockRuleFindFirst.mockResolvedValue({ targetType: 'article', - targetId: 'kb_article_1', + targetId: 'article_1', }) mockArticleFindFirst.mockResolvedValue({ slug: 'getting-started', @@ -224,7 +224,7 @@ describe('resolveRedirectRule', () => { }) it('returns null when the article target is no longer published', async () => { - mockRuleFindFirst.mockResolvedValue({ targetType: 'article', targetId: 'kb_article_1' }) + mockRuleFindFirst.mockResolvedValue({ targetType: 'article', targetId: 'article_1' }) mockArticleFindFirst.mockResolvedValue({ slug: 'getting-started', publishedAt: null, diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-related-articles.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-related-articles.test.ts index 4c97e94c83..c7dae1e0f5 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-related-articles.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-related-articles.test.ts @@ -75,14 +75,14 @@ vi.mock('@/lib/server/db', () => ({ import { getRelatedArticles } from '../help-center-related.service' const SOURCE = { - id: 'kb_article_1', + id: 'article_1', title: 'Invite your teammates', categoryId: 'kb_category_1', embedding: null as number[] | null, } const CANDIDATE = { - id: 'kb_article_2', + id: 'article_2', slug: 'manage-team-roles', title: 'Manage team roles', description: 'Roles and permissions', @@ -97,7 +97,7 @@ beforeEach(() => { describe('getRelatedArticles', () => { it('returns an empty list when the source article does not exist', async () => { resultsQueue.push([]) - await expect(getRelatedArticles('kb_article_missing')).resolves.toEqual([]) + await expect(getRelatedArticles('article_missing')).resolves.toEqual([]) }) it('ranks candidates by the source article embedding when one is stored', async () => { @@ -118,7 +118,7 @@ describe('getRelatedArticles', () => { it('pads scarce matches with recent articles from the same category', async () => { const pad = { - id: 'kb_article_3', + id: 'article_3', slug: 'set-up-your-workspace', title: 'Set up your workspace', description: null, @@ -134,7 +134,7 @@ describe('getRelatedArticles', () => { it('never repeats a candidate already ranked into the list', async () => { const other = { - id: 'kb_article_4', + id: 'article_4', slug: 'import-your-data', title: 'Import your data', description: null, diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-search-ranked.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-search-ranked.test.ts index 62e03c9343..1e9c0aa490 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-search-ranked.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-search-ranked.test.ts @@ -66,18 +66,18 @@ beforeEach(() => { describe('searchArticleIdsRanked', () => { it('returns ranked ids from the semantic path when embeddings are available', async () => { mockGenerateKbEmbedding.mockResolvedValue([0.1, 0.2]) - mockLimit.mockResolvedValue([{ id: 'kb_article_2' }, { id: 'kb_article_1' }]) + mockLimit.mockResolvedValue([{ id: 'article_2' }, { id: 'article_1' }]) const ids = await searchArticleIdsRanked('invite teammates', { audience: 'team' }) - expect(ids).toEqual(['kb_article_2', 'kb_article_1']) + expect(ids).toEqual(['article_2', 'article_1']) }) it('falls back to keyword ranking when embeddings are unavailable', async () => { mockGenerateKbEmbedding.mockResolvedValue(null) - mockLimit.mockResolvedValue([{ id: 'kb_article_3' }]) + mockLimit.mockResolvedValue([{ id: 'article_3' }]) const ids = await searchArticleIdsRanked('billing', { audience: 'team' }) - expect(ids).toEqual(['kb_article_3']) + expect(ids).toEqual(['article_3']) }) it('team audience sees drafts and private categories', async () => { diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-translations.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-translations.service.test.ts index 0ba8db27a7..7c7b678df5 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-translations.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-translations.service.test.ts @@ -22,7 +22,7 @@ function createInsertChain() { chain.returning = vi.fn().mockResolvedValue([ { id: 'kb_article_translation_1', - articleId: 'kb_article_1', + articleId: 'article_1', locale: 'de', title: 'Titel', description: null, @@ -46,7 +46,7 @@ function createUpdateChain() { chain.returning = vi.fn().mockResolvedValue([ { id: 'kb_article_translation_1', - articleId: 'kb_article_1', + articleId: 'article_1', locale: 'de', title: 'Titel', description: null, @@ -110,29 +110,29 @@ beforeEach(() => { describe('article translations', () => { it('lists translations for an article', async () => { mockArticleTranslationFindMany.mockResolvedValue([{ locale: 'de' }]) - const result = await listArticleTranslations('kb_article_1' as KbArticleId) + const result = await listArticleTranslations('article_1' as KbArticleId) expect(result).toEqual([{ locale: 'de' }]) }) it('returns null when a translation does not exist', async () => { mockArticleTranslationFindFirst.mockResolvedValue(undefined) - expect(await getArticleTranslation('kb_article_1' as KbArticleId, 'de')).toBeNull() + expect(await getArticleTranslation('article_1' as KbArticleId, 'de')).toBeNull() }) it('getPublishedArticleTranslation returns null for a draft translation', async () => { mockArticleTranslationFindFirst.mockResolvedValue({ status: 'draft' }) - expect(await getPublishedArticleTranslation('kb_article_1' as KbArticleId, 'de')).toBeNull() + expect(await getPublishedArticleTranslation('article_1' as KbArticleId, 'de')).toBeNull() }) it('getPublishedArticleTranslation returns the row when published', async () => { const row = { status: 'published', title: 'Titel' } mockArticleTranslationFindFirst.mockResolvedValue(row) - expect(await getPublishedArticleTranslation('kb_article_1' as KbArticleId, 'de')).toEqual(row) + expect(await getPublishedArticleTranslation('article_1' as KbArticleId, 'de')).toEqual(row) }) it('upserts via insert + onConflictDoUpdate on (articleId, locale)', async () => { const result = await upsertArticleTranslation({ - articleId: 'kb_article_1' as KbArticleId, + articleId: 'article_1' as KbArticleId, locale: 'de', title: 'Titel', content: 'Inhalt', @@ -143,11 +143,7 @@ describe('article translations', () => { }) it('sets translation status and errors when the translation does not exist', async () => { - const result = await setArticleTranslationStatus( - 'kb_article_1' as KbArticleId, - 'de', - 'published' - ) + const result = await setArticleTranslationStatus('article_1' as KbArticleId, 'de', 'published') expect(result.status).toBe('published') }) @@ -161,12 +157,12 @@ describe('article translations', () => { } as any) await expect( - setArticleTranslationStatus('kb_article_1' as KbArticleId, 'de', 'published') + setArticleTranslationStatus('article_1' as KbArticleId, 'de', 'published') ).rejects.toThrow(/before publishing/i) }) it('deletes a translation', async () => { - await deleteArticleTranslation('kb_article_1' as KbArticleId, 'de') + await deleteArticleTranslation('article_1' as KbArticleId, 'de') // no throw is sufficient; the mocked db.delete().where() always resolves }) @@ -176,7 +172,7 @@ describe('article translations', () => { { locale: 'fr', status: 'draft', updatedAt: new Date('2026-01-03') }, ]) - const statuses = await getArticleTranslationStatuses('kb_article_1' as KbArticleId, [ + const statuses = await getArticleTranslationStatuses('article_1' as KbArticleId, [ 'de', 'fr', 'es', diff --git a/apps/web/src/lib/server/mcp/tools/help-center.ts b/apps/web/src/lib/server/mcp/tools/help-center.ts index fda266df59..444527cddf 100644 --- a/apps/web/src/lib/server/mcp/tools/help-center.ts +++ b/apps/web/src/lib/server/mcp/tools/help-center.ts @@ -17,7 +17,7 @@ import { updateCategory, deleteCategory, } from '@/lib/server/domains/help-center/help-center.service' -import { parseOptionalTypeId } from '@/lib/server/domains/api/validation' +import { parseOptionalTypeId, parseTypeId } from '@/lib/server/domains/api/validation' import type { PrincipalId, KbArticleId, KbCategoryId } from '@quackback/ids' import type { McpAuthContext } from '../types' import { @@ -181,15 +181,16 @@ Examples: description: `Update a help center article. All fields optional — only provided fields change. Set publishedAt to any ISO datetime string to publish immediately, or null to unpublish. Examples: -- Update title: update_article({ articleId: "kb_article_01abc...", title: "New Title" }) -- Publish: update_article({ articleId: "kb_article_01abc...", publishedAt: "2026-04-08T00:00:00Z" }) -- Unpublish: update_article({ articleId: "kb_article_01abc...", publishedAt: null })${CONTENT_FORMAT_BLOCK}`, +- Update title: update_article({ articleId: "article_01abc...", title: "New Title" }) +- Publish: update_article({ articleId: "article_01abc...", publishedAt: "2026-04-08T00:00:00Z" }) +- Unpublish: update_article({ articleId: "article_01abc...", publishedAt: null })${CONTENT_FORMAT_BLOCK}`, schema: updateHelpCenterArticleSchema, annotations: WRITE, feature: 'helpCenter', scope: 'write:article', teamOnly: true, handler: async (args) => { + const articleId = parseTypeId(args.articleId, 'article', 'article ID') const authorPrincipalId = parseOptionalTypeId( args.authorId, 'principal', @@ -204,18 +205,18 @@ Examples: // never leaves the article in a partially-published state. let article = null if (hasUpdates) { - article = await updateArticle(args.articleId as KbArticleId, updateData, authorPrincipalId) + article = await updateArticle(articleId, updateData, authorPrincipalId) } if (args.publishedAt !== undefined) { article = args.publishedAt === null - ? await unpublishArticle(args.articleId as KbArticleId) - : await publishArticle(args.articleId as KbArticleId) + ? await unpublishArticle(articleId) + : await publishArticle(articleId) } if (!article) { - article = await getArticleById(args.articleId as KbArticleId) + article = await getArticleById(articleId) } return articleResult(article) @@ -227,15 +228,16 @@ Examples: description: `Soft-delete a help center article. Example: -- delete_article({ articleId: "kb_article_01abc..." })`, +- delete_article({ articleId: "article_01abc..." })`, schema: deleteHelpCenterArticleSchema, annotations: DESTRUCTIVE, feature: 'helpCenter', scope: 'write:article', teamOnly: true, handler: async (args) => { - await deleteArticle(args.articleId as KbArticleId) - return jsonResult({ deleted: true, id: args.articleId }) + const articleId = parseTypeId(args.articleId, 'article', 'article ID') + await deleteArticle(articleId) + return jsonResult({ deleted: true, id: articleId }) }, }) diff --git a/apps/web/src/lib/server/mcp/tools/search.ts b/apps/web/src/lib/server/mcp/tools/search.ts index d59b38aa87..0838276e75 100644 --- a/apps/web/src/lib/server/mcp/tools/search.ts +++ b/apps/web/src/lib/server/mcp/tools/search.ts @@ -19,7 +19,7 @@ import { getArticleById, getCategoryById, } from '@/lib/server/domains/help-center/help-center.service' -import { getTypeIdPrefix } from '@quackback/ids' +import { ensureTypeId, getTypeIdPrefix } from '@quackback/ids' import { truncate } from '@/lib/shared/utils/string' import { contentJsonToMarkdown } from '@/lib/server/markdown-tiptap' import type { CommentTreeNode } from '@/lib/shared/comment-tree' @@ -199,7 +199,7 @@ Examples: Examples: - Get a post: get_details({ id: "post_01abc..." }) - Get a changelog: get_details({ id: "changelog_01xyz..." }) -- Get an article: get_details({ id: "kb_article_01abc..." }) +- Get an article: get_details({ id: "article_01abc..." }) - Get a category: get_details({ id: "kb_category_01abc..." })`, schema: getDetailsSchema, annotations: READ_ONLY, @@ -212,7 +212,7 @@ Examples: } catch { return errorResult( new Error( - `Invalid TypeID format: "${args.id}". Expected format: prefix_base32suffix (e.g., post_01abc..., kb_article_01abc...)` + `Invalid TypeID format: "${args.id}". Expected format: prefix_base32suffix (e.g., post_01abc..., article_01abc...)` ) ) } @@ -237,6 +237,7 @@ Examples: if (roleDenied) return roleDenied return getChangelogDetails(args.id as ChangelogId) } + case 'article': case 'kb_article': { const flagDenied = await requireHelpCenter() if (flagDenied) return flagDenied @@ -249,7 +250,7 @@ Examples: // unauthenticated path for the published slice. const roleDenied = requireTeamRole(auth) if (roleDenied) return roleDenied - return getArticleDetails(args.id as KbArticleId) + return getArticleDetails(ensureTypeId(args.id, 'article')) } case 'kb_category': { const flagDenied = await requireHelpCenter() @@ -265,7 +266,7 @@ Examples: default: return errorResult( new Error( - `Unsupported entity type: "${prefix}". Supported: post, changelog, kb_article, kb_category` + `Unsupported entity type: "${prefix}". Supported: post, changelog, article, kb_category` ) ) } diff --git a/apps/web/src/lib/shared/schemas/__tests__/help-center.test.ts b/apps/web/src/lib/shared/schemas/__tests__/help-center.test.ts index 49cb55c1bd..e8ab79dfdf 100644 --- a/apps/web/src/lib/shared/schemas/__tests__/help-center.test.ts +++ b/apps/web/src/lib/shared/schemas/__tests__/help-center.test.ts @@ -193,7 +193,7 @@ describe('createArticleSchema', () => { describe('updateArticleSchema', () => { it('accepts partial update', () => { const result = updateArticleSchema.safeParse({ - id: 'kb_article_1', + id: 'article_1', title: 'Updated Title', }) expect(result.success).toBe(true) @@ -241,7 +241,7 @@ describe('listArticlesSchema', () => { describe('articleFeedbackSchema', () => { it('accepts valid feedback', () => { const result = articleFeedbackSchema.safeParse({ - articleId: 'kb_article_1', + articleId: 'article_1', helpful: true, }) expect(result.success).toBe(true) @@ -253,13 +253,13 @@ describe('articleFeedbackSchema', () => { }) it('rejects missing helpful', () => { - const result = articleFeedbackSchema.safeParse({ articleId: 'kb_article_1' }) + const result = articleFeedbackSchema.safeParse({ articleId: 'article_1' }) expect(result.success).toBe(false) }) it('rejects non-boolean helpful', () => { const result = articleFeedbackSchema.safeParse({ - articleId: 'kb_article_1', + articleId: 'article_1', helpful: 'yes', }) expect(result.success).toBe(false) diff --git a/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts b/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts index 4670413956..eabcbf5dcb 100644 --- a/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts +++ b/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts @@ -4,12 +4,12 @@ import { articleTypeIdToKbArticleId, isArticleTypeId } from '../article-ref' describe('article TypeID refs', () => { it('treats article_ and kb_article_ as the same row', () => { - const stored = generateId('kb_article') - const published = `article_${stored.slice('kb_article_'.length)}` - expect(isArticleTypeId(stored)).toBe(true) - expect(isArticleTypeId(published)).toBe(true) - expect(articleTypeIdToKbArticleId(published)).toBe(stored) - expect(articleTypeIdToKbArticleId(stored)).toBe(stored) + const canonical = generateId('article') + const legacy = `kb_article_${canonical.slice('article_'.length)}` + expect(isArticleTypeId(canonical)).toBe(true) + expect(isArticleTypeId(legacy)).toBe(true) + expect(articleTypeIdToKbArticleId(legacy)).toBe(canonical) + expect(articleTypeIdToKbArticleId(canonical)).toBe(canonical) }) it('rejects slugs and the old art_ prefix', () => { diff --git a/apps/web/src/lib/shared/widget/article-ref.ts b/apps/web/src/lib/shared/widget/article-ref.ts index dd205cbaf5..398444219b 100644 --- a/apps/web/src/lib/shared/widget/article-ref.ts +++ b/apps/web/src/lib/shared/widget/article-ref.ts @@ -1,31 +1,18 @@ -import { fromUuid, isValidTypeId, parseTypeId, type KbArticleId } from '@quackback/ids' - -/** Public `article_` plus the stored help-center prefix. */ -const ARTICLE_TYPEID_PREFIXES = new Set(['article', 'kb_article']) +import { ensureTypeId, isValidTypeId, type ArticleId } from '@quackback/ids' /** - * True for an article TypeID (`article_…` or `kb_article_…`). + * True for an article TypeID (`article_…` or the retired `kb_article_…` alias). * Slugs and the old undocumented `art_` prefix are not. */ export function isArticleTypeId(ref: string): boolean { - if (!isValidTypeId(ref)) return false - try { - return ARTICLE_TYPEID_PREFIXES.has(parseTypeId(ref).prefix) - } catch { - return false - } + return isValidTypeId(ref, 'article') } /** - * Map an `article_` / `kb_article_` TypeID onto the stored `KbArticleId`. - * Same UUID, canonical prefix — so a public `article_` id looks up the row. + * Canonical `article_…` TypeID for a public or stored article ref. + * Same UUID either way — so a retired `kb_article_…` id looks up the row. */ -export function articleTypeIdToKbArticleId(ref: string): KbArticleId | null { - try { - const { prefix, uuid } = parseTypeId(ref) - if (!ARTICLE_TYPEID_PREFIXES.has(prefix)) return null - return fromUuid('kb_article', uuid) - } catch { - return null - } +export function articleTypeIdToKbArticleId(ref: string): ArticleId | null { + if (!isValidTypeId(ref, 'article')) return null + return ensureTypeId(ref, 'article') } diff --git a/apps/web/src/routes/api/v1/help-center/__tests__/articles.test.ts b/apps/web/src/routes/api/v1/help-center/__tests__/articles.test.ts index dec74f5733..ff73259540 100644 --- a/apps/web/src/routes/api/v1/help-center/__tests__/articles.test.ts +++ b/apps/web/src/routes/api/v1/help-center/__tests__/articles.test.ts @@ -100,7 +100,7 @@ const mockAuthContext: ApiAuthContext = { } const mockArticle: HelpCenterArticleWithCategory = { - id: 'kb_article_1' as KbArticleId, + id: 'article_1' as KbArticleId, categoryId: 'kb_category_1' as KbCategoryId, slug: 'how-to-start', title: 'How to Get Started', @@ -150,7 +150,7 @@ describe('GET /api/v1/help-center/articles', () => { expect(response.status).toBe(200) const json = await response.json() expect(json.data).toHaveLength(1) - expect(json.data[0].id).toBe('kb_article_1') + expect(json.data[0].id).toBe('article_1') expect(json.data[0].title).toBe('How to Get Started') expect(json.data[0].publishedAt).toBe('2026-01-15T00:00:00.000Z') expect(json.meta.pagination).toEqual({ cursor: null, hasMore: false }) @@ -212,7 +212,7 @@ describe('POST /api/v1/help-center/articles', () => { expect(response.status).toBe(201) const json = await response.json() - expect(json.data.id).toBe('kb_article_1') + expect(json.data.id).toBe('article_1') expect(createArticle).toHaveBeenCalledWith(body, 'principal_1', undefined) }) @@ -315,12 +315,12 @@ describe('GET /api/v1/help-center/articles/:id', () => { const request = createRequest('GET', 'http://localhost/api/v1/help-center/articles/article_1') const response = await detailHandlers.GET({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(200) const json = await response.json() - expect(json.data.id).toBe('kb_article_1') + expect(json.data.id).toBe('article_1') expect(json.data.category).toEqual({ id: 'kb_category_1', urlId: 1, @@ -368,17 +368,13 @@ describe('PATCH /api/v1/help-center/articles/:id', () => { ) const response = await detailHandlers.PATCH({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(200) const json = await response.json() expect(json.data.title).toBe('Updated Title') - expect(updateArticle).toHaveBeenCalledWith( - 'kb_article_1', - { title: 'Updated Title' }, - undefined - ) + expect(updateArticle).toHaveBeenCalledWith('article_1', { title: 'Updated Title' }, undefined) }) it('reassigns author when authorId is provided', async () => { @@ -397,11 +393,11 @@ describe('PATCH /api/v1/help-center/articles/:id', () => { ) const response = await detailHandlers.PATCH({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(200) - expect(updateArticle).toHaveBeenCalledWith('kb_article_1', {}, 'principal_2') + expect(updateArticle).toHaveBeenCalledWith('article_1', {}, 'principal_2') }) it('returns 400 when authorId format is invalid', async () => { @@ -417,7 +413,7 @@ describe('PATCH /api/v1/help-center/articles/:id', () => { ) const response = await detailHandlers.PATCH({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(400) @@ -437,7 +433,7 @@ describe('PATCH /api/v1/help-center/articles/:id', () => { ) const response = await detailHandlers.PATCH({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(400) @@ -457,11 +453,11 @@ describe('PATCH /api/v1/help-center/articles/:id', () => { ) const response = await detailHandlers.PATCH({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(200) - expect(publishArticle).toHaveBeenCalledWith('kb_article_1') + expect(publishArticle).toHaveBeenCalledWith('article_1') expect(updateArticle).not.toHaveBeenCalled() }) @@ -477,11 +473,11 @@ describe('PATCH /api/v1/help-center/articles/:id', () => { ) const response = await detailHandlers.PATCH({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(200) - expect(unpublishArticle).toHaveBeenCalledWith('kb_article_1') + expect(unpublishArticle).toHaveBeenCalledWith('article_1') expect(updateArticle).not.toHaveBeenCalled() }) @@ -498,7 +494,7 @@ describe('PATCH /api/v1/help-center/articles/:id', () => { ) const response = await detailHandlers.PATCH({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(403) @@ -513,7 +509,7 @@ describe('PATCH /api/v1/help-center/articles/:id', () => { ) const response = await detailHandlers.PATCH({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(400) @@ -539,11 +535,11 @@ describe('DELETE /api/v1/help-center/articles/:id', () => { ) const response = await detailHandlers.DELETE({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(204) - expect(deleteArticle).toHaveBeenCalledWith('kb_article_1') + expect(deleteArticle).toHaveBeenCalledWith('article_1') }) it('requires admin role', async () => { @@ -557,7 +553,7 @@ describe('DELETE /api/v1/help-center/articles/:id', () => { ) const response = await detailHandlers.DELETE({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(403) @@ -585,13 +581,13 @@ describe('POST /api/v1/help-center/articles/:id/feedback', () => { ) const response = await feedbackHandlers.POST({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(200) const json = await response.json() expect(json.data.success).toBe(true) - expect(recordArticleFeedback).toHaveBeenCalledWith('kb_article_1', true, 'principal_1') + expect(recordArticleFeedback).toHaveBeenCalledWith('article_1', true, 'principal_1') }) it('records helpful=false feedback', async () => { @@ -607,11 +603,11 @@ describe('POST /api/v1/help-center/articles/:id/feedback', () => { ) const response = await feedbackHandlers.POST({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(200) - expect(recordArticleFeedback).toHaveBeenCalledWith('kb_article_1', false, 'principal_1') + expect(recordArticleFeedback).toHaveBeenCalledWith('article_1', false, 'principal_1') }) it('returns 400 for invalid body (missing helpful field)', async () => { @@ -623,7 +619,7 @@ describe('POST /api/v1/help-center/articles/:id/feedback', () => { ) const response = await feedbackHandlers.POST({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(400) @@ -644,7 +640,7 @@ describe('POST /api/v1/help-center/articles/:id/feedback', () => { ) const response = await feedbackHandlers.POST({ request, - params: { articleId: 'kb_article_1' }, + params: { articleId: 'article_1' }, }) expect(response.status).toBe(403) diff --git a/apps/web/src/routes/api/v1/help-center/articles/$articleId.feedback.ts b/apps/web/src/routes/api/v1/help-center/articles/$articleId.feedback.ts index c2ec8069fe..f1f93026a5 100644 --- a/apps/web/src/routes/api/v1/help-center/articles/$articleId.feedback.ts +++ b/apps/web/src/routes/api/v1/help-center/articles/$articleId.feedback.ts @@ -25,7 +25,7 @@ export const Route = createFileRoute('/api/v1/help-center/articles/$articleId/fe try { const { principalId } = await withApiKeyAuth(request) - const articleId = parseTypeId(params.articleId, 'kb_article', 'article ID') + const articleId = parseTypeId(params.articleId, 'article', 'article ID') const body = await request.json() const parsed = feedbackBody.safeParse(body) diff --git a/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts b/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts index 96092d564c..0bb3cc623a 100644 --- a/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts +++ b/apps/web/src/routes/api/v1/help-center/articles/$articleId.ts @@ -76,7 +76,7 @@ export const Route = createFileRoute('/api/v1/help-center/articles/$articleId')( try { await withApiKeyAuth(request) - const articleId = parseTypeId(params.articleId, 'kb_article', 'article ID') + const articleId = parseTypeId(params.articleId, 'article', 'article ID') const article = await getArticleById(articleId) return successResponse(formatArticle(article)) @@ -91,7 +91,7 @@ export const Route = createFileRoute('/api/v1/help-center/articles/$articleId')( try { await withApiKeyAuth(request, { permission: PERMISSIONS.HELP_CENTER_MANAGE }) - const articleId = parseTypeId(params.articleId, 'kb_article', 'article ID') + const articleId = parseTypeId(params.articleId, 'article', 'article ID') const body = await request.json() const parsed = updateArticleBody.safeParse(body) @@ -146,7 +146,7 @@ export const Route = createFileRoute('/api/v1/help-center/articles/$articleId')( // Soft delete (deleteArticle sets deletedAt). await withApiKeyAuth(request, { permission: PERMISSIONS.HELP_CENTER_MANAGE }) - const articleId = parseTypeId(params.articleId, 'kb_article', 'article ID') + const articleId = parseTypeId(params.articleId, 'article', 'article ID') await deleteArticle(articleId) return noContentResponse() diff --git a/apps/web/src/routes/api/widget/__tests__/kb-ask.test.ts b/apps/web/src/routes/api/widget/__tests__/kb-ask.test.ts index 7dd5dedbb4..7323f18bdb 100644 --- a/apps/web/src/routes/api/widget/__tests__/kb-ask.test.ts +++ b/apps/web/src/routes/api/widget/__tests__/kb-ask.test.ts @@ -98,19 +98,19 @@ beforeEach(() => { mockLogAiUsage.mockResolvedValue(undefined) mockGetChatModel.mockReturnValue('gpt-test') mockGetSettings.mockResolvedValue({ id: 'settings_1' }) - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) mockSynthesize.mockResolvedValue({ kind: 'grounded', answer: 'Do the thing.', - sources: [{ articleId: 'kb_article_1' }], + sources: [{ articleId: 'article_1' }], }) }) const SOURCE_META = { - articleId: 'kb_article_1', + articleId: 'article_1', urlId: 1, - title: 'Title kb_article_1', - slug: 'slug-kb_article_1', + title: 'Title article_1', + slug: 'slug-article_1', categorySlug: 'general', categoryName: 'General', } @@ -266,7 +266,7 @@ describe('POST /api/widget/kb-ask', () => { return { kind: 'grounded', answer: 'Do the thing.', - sources: [{ articleId: 'kb_article_1' }], + sources: [{ articleId: 'article_1' }], } } ) @@ -301,13 +301,13 @@ describe('POST /api/widget/kb-ask', () => { expect(finished.result).toEqual({ kind: 'grounded', answer: 'Do the thing.', - sources: [{ articleId: 'kb_article_1' }], + sources: [{ articleId: 'article_1' }], }) }) it('short-circuits on empty retrieval: no snapshot, no model call, RUN_FINISHED miss with related', async () => { mockRetrieve.mockImplementation(async (_q: string, opts?: { minScore?: number }) => - opts?.minScore !== undefined ? [makeKbArticle('kb_article_9')] : [] + opts?.minScore !== undefined ? [makeKbArticle('article_9')] : [] ) const res = await handleKbAsk({ request: makePost('gibberish') }) @@ -322,10 +322,10 @@ describe('POST /api/widget/kb-ask', () => { sources: [], related: [ { - articleId: 'kb_article_9', + articleId: 'article_9', urlId: 9, - title: 'Title kb_article_9', - slug: 'slug-kb_article_9', + title: 'Title article_9', + slug: 'slug-article_9', categorySlug: 'general', categoryName: 'General', }, @@ -335,7 +335,7 @@ describe('POST /api/widget/kb-ask', () => { it('logs a no_sources ai usage entry on the empty-retrieval short-circuit', async () => { mockRetrieve.mockImplementation(async (_q: string, opts?: { minScore?: number }) => - opts?.minScore !== undefined ? [makeKbArticle('kb_article_9')] : [] + opts?.minScore !== undefined ? [makeKbArticle('article_9')] : [] ) const res = await handleKbAsk({ request: makePost('gibberish') }) await res.text() @@ -356,7 +356,7 @@ describe('POST /api/widget/kb-ask', () => { }) it('reuses the retrieved articles as related suggestions on a no-answer', async () => { - mockRetrieve.mockResolvedValue([makeKbArticle('kb_article_1')]) + mockRetrieve.mockResolvedValue([makeKbArticle('article_1')]) mockSynthesize.mockResolvedValue({ kind: 'no_answer', answer: 'I could not find a specific answer to that.', @@ -370,7 +370,7 @@ describe('POST /api/widget/kb-ask', () => { ).result! expect(result.kind).toBe('no_answer') - expect(result.related.map((r) => r.articleId)).toEqual(['kb_article_1']) + expect(result.related.map((r) => r.articleId)).toEqual(['article_1']) // The retrieved set was reused; no second retrieval call. expect(mockRetrieve).toHaveBeenCalledTimes(1) }) diff --git a/packages/db/src/schema/kb.ts b/packages/db/src/schema/kb.ts index dbf90e67a2..7eeb7c03a7 100644 --- a/packages/db/src/schema/kb.ts +++ b/packages/db/src/schema/kb.ts @@ -118,7 +118,7 @@ export const helpCenterCategories = pgTable( export const helpCenterArticles = pgTable( 'kb_articles', { - id: typeIdWithDefault('kb_article')('id').primaryKey(), + id: typeIdWithDefault('article')('id').primaryKey(), categoryId: typeIdColumn('kb_category')('category_id') .notNull() .references(() => helpCenterCategories.id, { onDelete: 'cascade' }), @@ -173,7 +173,7 @@ export const helpCenterArticleFeedback = pgTable( 'kb_article_feedback', { id: typeIdWithDefault('kb_article_feedback')('id').primaryKey(), - articleId: typeIdColumn('kb_article')('article_id') + articleId: typeIdColumn('article')('article_id') .notNull() .references(() => helpCenterArticles.id, { onDelete: 'cascade' }), principalId: typeIdColumnNullable('principal')('principal_id').references(() => principal.id, { @@ -265,7 +265,7 @@ export const helpCenterArticleTranslations = pgTable( 'kb_article_translations', { id: typeIdWithDefault('kb_article_translation')('id').primaryKey(), - articleId: typeIdColumn('kb_article')('article_id') + articleId: typeIdColumn('article')('article_id') .notNull() .references(() => helpCenterArticles.id, { onDelete: 'cascade' }), locale: text('locale').notNull(), diff --git a/packages/ids/src/__tests__/core.test.ts b/packages/ids/src/__tests__/core.test.ts index c7e7556ae9..21e5aea49d 100644 --- a/packages/ids/src/__tests__/core.test.ts +++ b/packages/ids/src/__tests__/core.test.ts @@ -274,4 +274,22 @@ describe('TypeID Core', () => { expect(() => ensureTypeId(boardId, 'post')).toThrow('Invalid post ID') }) }) + + describe('article prefix alias', () => { + it('createId(kb_article) emits article_', () => { + const id = createId('kb_article') + expect(id).toMatch(/^article_/) + expect(ID_PREFIXES.kb_article).toBe('article') + }) + + it('accepts retired kb_article_ ids as article ids', () => { + const canonical = generateId('article') + const legacy = `kb_article_${canonical.slice('article_'.length)}` + expect(isValidTypeId(legacy, 'article')).toBe(true) + expect(isValidTypeId(canonical, 'article')).toBe(true) + expect(toUuid(legacy)).toBe(toUuid(canonical)) + expect(ensureTypeId(legacy, 'article')).toBe(canonical) + expect(normalizeToUuid(legacy, 'article')).toBe(toUuid(canonical)) + }) + }) }) diff --git a/packages/ids/src/__tests__/zod.test.ts b/packages/ids/src/__tests__/zod.test.ts index 0e478fe028..95ae7fd6db 100644 --- a/packages/ids/src/__tests__/zod.test.ts +++ b/packages/ids/src/__tests__/zod.test.ts @@ -44,6 +44,14 @@ describe('Zod TypeID Schemas', () => { expect(() => schema.parse('')).toThrow() expect(() => schema.parse('post_invalid')).toThrow() }) + + it('accepts the retired kb_article_ alias for article ids', () => { + const schema = typeIdSchema('article') + const canonical = generateId('article') + const legacy = `kb_article_${canonical.slice('article_'.length)}` + expect(schema.parse(canonical)).toBe(canonical) + expect(schema.parse(legacy)).toBe(legacy) + }) }) describe('flexibleIdSchema', () => { diff --git a/packages/ids/src/core.ts b/packages/ids/src/core.ts index db05feccf8..b5793e0b42 100644 --- a/packages/ids/src/core.ts +++ b/packages/ids/src/core.ts @@ -12,7 +12,7 @@ */ import { typeid, TypeID } from 'typeid-js' -import { ID_PREFIXES, type IdPrefix, type EntityType } from './prefixes' +import { ID_PREFIXES, prefixMatches, type IdPrefix, type EntityType } from './prefixes' import type { TypeId, EntityIdMap } from './types' /** @@ -147,7 +147,7 @@ export function getTypeIdPrefix(typeIdString: string): string { export function isValidTypeId(value: string, expectedPrefix?: IdPrefix): boolean { try { const tid = TypeID.fromString(value) - if (expectedPrefix && tid.getType() !== expectedPrefix) { + if (expectedPrefix && !prefixMatches(tid.getType(), expectedPrefix)) { return false } // Also verify the suffix is valid base32 by attempting UUID conversion @@ -242,8 +242,8 @@ export function normalizeToUuid(id: string, expectedPrefix?: IdPrefix): string { // Parse as TypeID const parsed = parseTypeId(id) - // Validate prefix if specified - if (expectedPrefix && parsed.prefix !== expectedPrefix) { + // Validate prefix if specified (aliases of the expected prefix are ok) + if (expectedPrefix && !prefixMatches(parsed.prefix, expectedPrefix)) { throw new Error(`Expected ${expectedPrefix} ID, got ${parsed.prefix}`) } @@ -267,10 +267,15 @@ export function ensureTypeId

(id: string, prefix: P): TypeId< return fromUuid(prefix, id) } - // Validate it's a TypeID with correct prefix + // Validate it's a TypeID with the correct prefix (or a retired alias) if (!isValidTypeId(id, prefix)) { throw new Error(`Invalid ${prefix} ID: ${id}`) } + const parsed = parseTypeId(id) + if (parsed.prefix !== prefix) { + return fromUuid(prefix, parsed.uuid) + } + return id as TypeId

} diff --git a/packages/ids/src/index.ts b/packages/ids/src/index.ts index 8563f8fac6..92fff3671f 100644 --- a/packages/ids/src/index.ts +++ b/packages/ids/src/index.ts @@ -56,7 +56,16 @@ export { // Prefixes // ============================================ -export { ID_PREFIXES, getPrefix, isValidPrefix, type IdPrefix, type EntityType } from './prefixes' +export { + ID_PREFIXES, + ID_PREFIX_ALIASES, + getPrefix, + isValidPrefix, + prefixMatches, + resolvePrefix, + type IdPrefix, + type EntityType, +} from './prefixes' // ============================================ // Types @@ -155,6 +164,7 @@ export type { PostMergeSuggestionId, // Help center entities KbCategoryId, + ArticleId, KbArticleId, KbArticleFeedbackId, HcRedirectRuleId, @@ -210,6 +220,7 @@ export { // Pre-built strict schemas postIdSchema, boardIdSchema, + articleIdSchema, commentIdSchema, voteIdSchema, tagIdSchema, diff --git a/packages/ids/src/prefixes.ts b/packages/ids/src/prefixes.ts index a0726e4acf..f1e542bb87 100644 --- a/packages/ids/src/prefixes.ts +++ b/packages/ids/src/prefixes.ts @@ -51,7 +51,8 @@ export const ID_PREFIXES = { // Help center kb_category: 'kb_category', - kb_article: 'kb_article', + // Serialized as `article_…` (same UUID as the retired `kb_article_…` prefix). + kb_article: 'article', kb_article_feedback: 'kb_article_feedback', hc_redirect_rule: 'hc_redirect_rule', kb_article_translation: 'kb_article_translation', @@ -222,6 +223,15 @@ export type IdPrefix = (typeof ID_PREFIXES)[keyof typeof ID_PREFIXES] */ export type EntityType = keyof typeof ID_PREFIXES +/** + * Retired serialized prefixes that still identify the same entity. + * Incoming IDs with these prefixes are accepted and rewritten to the + * canonical `ID_PREFIXES` value on ensure/parse. + */ +export const ID_PREFIX_ALIASES: Readonly> = { + kb_article: 'article', +} + /** * Get the prefix for a given entity type */ @@ -230,8 +240,23 @@ export function getPrefix(entity: EntityType): IdPrefix { } /** - * Check if a string is a valid prefix + * Check if a string is a valid canonical prefix */ export function isValidPrefix(prefix: string): prefix is IdPrefix { return Object.values(ID_PREFIXES).includes(prefix as IdPrefix) } + +/** + * True when `actual` is `expected` or a retired alias of it. + */ +export function prefixMatches(actual: string, expected: IdPrefix): boolean { + return actual === expected || ID_PREFIX_ALIASES[actual] === expected +} + +/** + * Map a serialized prefix (canonical or alias) onto the catalogue prefix. + */ +export function resolvePrefix(prefix: string): IdPrefix | undefined { + if (isValidPrefix(prefix)) return prefix + return ID_PREFIX_ALIASES[prefix] +} diff --git a/packages/ids/src/types.ts b/packages/ids/src/types.ts index e6806c7c52..d85b860058 100644 --- a/packages/ids/src/types.ts +++ b/packages/ids/src/types.ts @@ -321,8 +321,10 @@ export type PostMergeSuggestionId = TypeId<'post_merge_sug'> /** Help center category ID - e.g., kb_category_01h455vb4pex5vsknk084sn02q */ export type KbCategoryId = TypeId<'kb_category'> -/** Help center article ID - e.g., kb_article_01h455vb4pex5vsknk084sn02q */ -export type KbArticleId = TypeId<'kb_article'> +/** Help center article ID - e.g., article_01h455vb4pex5vsknk084sn02q */ +export type ArticleId = TypeId<'article'> +/** @deprecated Prefer `ArticleId`. Same type; inbound `kb_article_…` is rewritten. */ +export type KbArticleId = ArticleId /** Article feedback ID - e.g., kb_article_feedback_01h455vb4pex5vsknk084sn02q */ export type KbArticleFeedbackId = TypeId<'kb_article_feedback'> diff --git a/packages/ids/src/zod.ts b/packages/ids/src/zod.ts index 08aca6bbac..74bfc9bc15 100644 --- a/packages/ids/src/zod.ts +++ b/packages/ids/src/zod.ts @@ -7,7 +7,7 @@ import { z } from 'zod' import { TypeID } from 'typeid-js' -import { ID_PREFIXES, type IdPrefix } from './prefixes' +import { ID_PREFIXES, prefixMatches, type IdPrefix } from './prefixes' import type { TypeId } from './types' /** @@ -38,7 +38,7 @@ export function typeIdSchema

(prefix: P) { (val) => { try { const tid = TypeID.fromString(val) - return tid.getType() === prefix + return prefixMatches(tid.getType(), prefix) } catch { return false } @@ -74,8 +74,8 @@ export function flexibleIdSchema

(prefix: P) { try { const tid = TypeID.fromString(val) - // Validate prefix matches - if (tid.getType() !== prefix) { + // Validate prefix matches (aliases of the expected prefix are ok) + if (!prefixMatches(tid.getType(), prefix)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Expected ${prefix} ID, got ${tid.getType()} ID`, @@ -113,13 +113,16 @@ export function flexibleToTypeIdSchema

(prefix: P) { if (val.includes('_')) { try { const tid = TypeID.fromString(val) - if (tid.getType() !== prefix) { + if (!prefixMatches(tid.getType(), prefix)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Expected ${prefix} ID, got ${tid.getType()} ID`, }) return z.NEVER } + if (tid.getType() !== prefix) { + return TypeID.fromUUID(prefix, tid.toUUID()).toString() as TypeId

+ } return val as TypeId

} catch { ctx.addIssue({ @@ -170,6 +173,7 @@ export const uuidSchema = z.string().regex(UUID_REGEX, 'Invalid UUID format') // Strict TypeID schemas (only accept TypeID format) export const postIdSchema = typeIdSchema(ID_PREFIXES.post) export const boardIdSchema = typeIdSchema(ID_PREFIXES.board) +export const articleIdSchema = typeIdSchema(ID_PREFIXES.kb_article) export const commentIdSchema = typeIdSchema(ID_PREFIXES.post_comment) export const voteIdSchema = typeIdSchema(ID_PREFIXES.post_vote) export const tagIdSchema = typeIdSchema(ID_PREFIXES.post_tag) From b1a091d3893fa5137b19aefe17cf4788bf120d28 Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 00:39:03 +0100 Subject: [PATCH 2/7] fix(ids): match legacy kb_article_ text in citations and redirects Copilot citation joins and redirect-rule cleanup now treat kb_article_ and article_ as the same UUID, so pre-prefix-flip JSON and text targets are not dropped after emit changes. Co-authored-by: Cursor --- .../analytics/__tests__/copilot-usage.test.ts | 20 ++++++++ .../server/domains/analytics/copilot-usage.ts | 44 ++++++++++++----- ...help-center-redirect-rules.service.test.ts | 11 +++++ .../help-center-redirect-rules.service.ts | 48 ++++++++++++++++--- packages/ids/src/__tests__/core.test.ts | 8 ++++ packages/ids/src/core.ts | 22 ++++++++- packages/ids/src/index.ts | 1 + 7 files changed, 135 insertions(+), 19 deletions(-) diff --git a/apps/web/src/lib/server/domains/analytics/__tests__/copilot-usage.test.ts b/apps/web/src/lib/server/domains/analytics/__tests__/copilot-usage.test.ts index 4cf163ce15..537a1747e6 100644 --- a/apps/web/src/lib/server/domains/analytics/__tests__/copilot-usage.test.ts +++ b/apps/web/src/lib/server/domains/analytics/__tests__/copilot-usage.test.ts @@ -487,6 +487,26 @@ describe.skipIf(!fixture.available)('getCopilotUsageMetrics (real DB)', () => { }) describe('topCitedSources', () => { + it('joins historical kb_article_ citation ids to the live article_ row', async () => { + const article = await seedArticle('Legacy citation') + const legacy = `kb_article_${article.slice('article_'.length)}` + await seedUsageLog('assistant', { + surface: 'copilot', + citedSources: [{ type: 'article', id: legacy }], + }) + + const metrics = await getCopilotUsageMetrics(FROM, TO) + expect(metrics.topCitedSources).toEqual([ + { + id: article, + title: 'Legacy citation', + url: `/admin/help-center/articles/${article}`, + questions: 1, + insertRate: null, + }, + ]) + }) + it('ranks cited articles by question volume, most first, joined to their title', async () => { const popular = await seedArticle('Resetting your password') const rare = await seedArticle('Exporting a report') diff --git a/apps/web/src/lib/server/domains/analytics/copilot-usage.ts b/apps/web/src/lib/server/domains/analytics/copilot-usage.ts index 708db0b59e..35a83d8fd8 100644 --- a/apps/web/src/lib/server/domains/analytics/copilot-usage.ts +++ b/apps/web/src/lib/server/domains/analytics/copilot-usage.ts @@ -100,7 +100,7 @@ import { assistantPendingActions, helpCenterArticles, } from '@/lib/server/db' -import type { KbArticleId, PrincipalId } from '@quackback/ids' +import { ensureTypeId, typeIdLookupKeys, type KbArticleId, type PrincipalId } from '@quackback/ids' import { loadAuthors } from '@/lib/server/domains/principals/principal-display' import { COPILOT_EVENT_TYPES } from '@/lib/shared/assistant/copilot-contract' import { ratePctOrNull } from '@/lib/shared/percent' @@ -112,6 +112,24 @@ const TOP_TEAMMATES_LIMIT = 10 * per-teammate list, not a full content audit. */ const TOP_CITED_SOURCES_LIMIT = 10 +function canonicalArticleId(id: string): KbArticleId | null { + try { + return ensureTypeId(id, 'article') + } catch { + return null + } +} + +function mergeCountsByCanonicalArticleId(rows: Array<{ id: string; n: number }>) { + const counts = new Map() + for (const row of rows) { + const id = canonicalArticleId(row.id) + if (!id) continue + counts.set(id, (counts.get(id) ?? 0) + row.n) + } + return counts +} + /** The `*_inserted` event kinds, derived from the shared vocabulary (never * hand-listed) so a new insert kind is counted here the day the contract * grows it. Derived by the same suffix rule the server fn's zod uses to @@ -424,7 +442,11 @@ export async function getCopilotUsageMetrics(from: Date, to: Date): Promise row.id as KbArticleId) + // Historical rows may still store `kb_article_…`; fold those onto `article_…` + // before joining titles or matching insert events. + const citedCounts = mergeCountsByCanonicalArticleId(citedSourceRows) + const citedArticleIds = [...citedCounts.keys()] + const citedLookupKeys = citedArticleIds.flatMap((id) => typeIdLookupKeys(id, 'article')) const [articles, sourceInsertRows] = await Promise.all([ citedArticleIds.length ? db @@ -455,7 +477,7 @@ export async function getCopilotUsageMetrics(from: Date, to: Date): Promise= ${from.toISOString()} AND created_at < ${to.toISOString()} AND elem = ANY(ARRAY[${sql.join( - citedArticleIds.map((id) => sql`${id}`), + citedLookupKeys.map((id) => sql`${id}`), sql`, ` )}]::text[]) GROUP BY elem @@ -463,18 +485,18 @@ export async function getCopilotUsageMetrics(from: Date, to: Date): Promise [a.id, a.title])) - const sourceInsertsById = new Map(sourceInsertRows.map((row) => [row.id, row.n])) - const topCitedSources: CopilotCitedSourceCount[] = citedSourceRows - .filter((row) => articleTitleById.has(row.id as KbArticleId)) - .map((row) => { - const id = row.id as KbArticleId - const inserted = sourceInsertsById.get(row.id) + const sourceInsertsById = mergeCountsByCanonicalArticleId(sourceInsertRows) + const topCitedSources: CopilotCitedSourceCount[] = citedArticleIds + .filter((id) => articleTitleById.has(id)) + .map((id) => { + const questions = citedCounts.get(id)! + const inserted = sourceInsertsById.get(id) return { id, title: articleTitleById.get(id)!, url: `/admin/help-center/articles/${id}`, - questions: row.n, - insertRate: inserted === undefined ? null : ratePctOrNull(inserted, row.n), + questions, + insertRate: inserted === undefined ? null : ratePctOrNull(inserted, questions), } }) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts index dddc232910..e10b879034 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts @@ -39,6 +39,7 @@ vi.mock('@/lib/server/db', () => ({ }, eq: (...args: unknown[]) => ({ op: 'eq', args }), and: (...args: unknown[]) => ({ op: 'and', args }), + inArray: (...args: unknown[]) => ({ op: 'inArray', args }), desc: (...args: unknown[]) => ({ op: 'desc', args }), helpCenterRedirectRules: { path: 'path', @@ -200,6 +201,16 @@ describe('deleteRedirectRule / deleteRedirectRulesForTarget', () => { await deleteRedirectRulesForTarget('article', 'article_1') expect(mockDeleteWhere).toHaveBeenCalled() }) + + it('matches redirect targets stored as kb_article_ when deleting an article_ id', async () => { + const { generateId } = await import('@quackback/ids') + const canonical = generateId('article') + const legacy = `kb_article_${canonical.slice('article_'.length)}` + await deleteRedirectRulesForTarget('article', canonical) + const clause = JSON.stringify(mockDeleteWhere.mock.calls[0]?.[0]) + expect(clause).toContain(canonical) + expect(clause).toContain(legacy) + }) }) describe('resolveRedirectRule', () => { diff --git a/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts b/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts index 8920b721e3..ea1c8a5da8 100644 --- a/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts +++ b/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts @@ -12,17 +12,40 @@ import { eq, and, desc, + inArray, helpCenterRedirectRules, helpCenterArticles, helpCenterCategories, } from '@/lib/server/db' -import type { KbArticleId, KbCategoryId, HcRedirectRuleId } from '@quackback/ids' +import { + ensureTypeId, + typeIdLookupKeys, + type KbArticleId, + type KbCategoryId, + type HcRedirectRuleId, +} from '@quackback/ids' import { NotFoundError, ValidationError, ConflictError, InternalError } from '@/lib/shared/errors' import { isUniqueViolation } from '@/lib/server/utils' import { logger } from '@/lib/server/logger' const log = logger.child({ component: 'help-center-redirect-rules' }) +function canonicalArticleTargetId(targetId: string): KbArticleId { + try { + return ensureTypeId(targetId, 'article') + } catch { + return targetId as KbArticleId + } +} + +function articleTargetLookupKeys(targetId: string): string[] { + try { + return typeIdLookupKeys(targetId, 'article') + } catch { + return [targetId] + } +} + export type RedirectTargetType = 'article' | 'category' export interface HelpCenterRedirectRule { @@ -54,7 +77,7 @@ async function requirePublishedTarget( ): Promise { if (targetType === 'article') { const article = await db.query.helpCenterArticles.findFirst({ - where: eq(helpCenterArticles.id, targetId as KbArticleId), + where: eq(helpCenterArticles.id, canonicalArticleTargetId(targetId)), columns: { title: true, publishedAt: true, deletedAt: true }, }) if (!article || article.deletedAt) { @@ -92,7 +115,7 @@ async function lookupTargetLabel( ): Promise { if (targetType === 'article') { const article = await db.query.helpCenterArticles.findFirst({ - where: eq(helpCenterArticles.id, targetId as KbArticleId), + where: eq(helpCenterArticles.id, canonicalArticleTargetId(targetId)), columns: { title: true }, }) return article?.title ?? null @@ -135,13 +158,23 @@ export async function createRedirectRule( try { const [row] = await db .insert(helpCenterRedirectRules) - .values({ path, targetType: input.targetType, targetId: input.targetId }) + .values({ + path, + targetType: input.targetType, + targetId: + input.targetType === 'article' + ? canonicalArticleTargetId(input.targetId) + : input.targetId, + }) .returning() return { ...row, targetLabel } } catch (error) { if (error instanceof ValidationError || error instanceof NotFoundError) throw error if (isUniqueViolation(error)) { - throw new ConflictError('HC_REDIRECT_PATH_TAKEN', `A redirect rule for "${path}" already exists`) + throw new ConflictError( + 'HC_REDIRECT_PATH_TAKEN', + `A redirect rule for "${path}" already exists` + ) } log.error({ err: error }, 'failed to create redirect rule') throw new InternalError('DATABASE_ERROR', 'Failed to create redirect rule', error) @@ -157,12 +190,13 @@ export async function deleteRedirectRulesForTarget( targetType: RedirectTargetType, targetId: string ): Promise { + const targetIds = targetType === 'article' ? articleTargetLookupKeys(targetId) : [targetId] await db .delete(helpCenterRedirectRules) .where( and( eq(helpCenterRedirectRules.targetType, targetType), - eq(helpCenterRedirectRules.targetId, targetId) + inArray(helpCenterRedirectRules.targetId, targetIds) ) ) } @@ -181,7 +215,7 @@ export async function resolveRedirectRule(path: string): Promise if (rule.targetType === 'article') { const article = await db.query.helpCenterArticles.findFirst({ - where: eq(helpCenterArticles.id, rule.targetId as KbArticleId), + where: eq(helpCenterArticles.id, canonicalArticleTargetId(rule.targetId)), with: { category: true }, }) if (!article || article.deletedAt || !article.publishedAt) return null diff --git a/packages/ids/src/__tests__/core.test.ts b/packages/ids/src/__tests__/core.test.ts index 21e5aea49d..47b3c62a5b 100644 --- a/packages/ids/src/__tests__/core.test.ts +++ b/packages/ids/src/__tests__/core.test.ts @@ -13,6 +13,7 @@ import { batchToUuid, normalizeToUuid, ensureTypeId, + typeIdLookupKeys, } from '../core' import { ID_PREFIXES } from '../prefixes' @@ -290,6 +291,13 @@ describe('TypeID Core', () => { expect(toUuid(legacy)).toBe(toUuid(canonical)) expect(ensureTypeId(legacy, 'article')).toBe(canonical) expect(normalizeToUuid(legacy, 'article')).toBe(toUuid(canonical)) + expect(typeIdLookupKeys(legacy, 'article')).toEqual([canonical, legacy]) + expect(typeIdLookupKeys(canonical, 'article')).toEqual([canonical, legacy]) + }) + + it('typeIdLookupKeys is only the canonical form when there is no alias', () => { + const postId = generateId('post') + expect(typeIdLookupKeys(postId, 'post')).toEqual([postId]) }) }) }) diff --git a/packages/ids/src/core.ts b/packages/ids/src/core.ts index b5793e0b42..c6f3a1a57c 100644 --- a/packages/ids/src/core.ts +++ b/packages/ids/src/core.ts @@ -12,7 +12,13 @@ */ import { typeid, TypeID } from 'typeid-js' -import { ID_PREFIXES, prefixMatches, type IdPrefix, type EntityType } from './prefixes' +import { + ID_PREFIXES, + ID_PREFIX_ALIASES, + prefixMatches, + type IdPrefix, + type EntityType, +} from './prefixes' import type { TypeId, EntityIdMap } from './types' /** @@ -279,3 +285,17 @@ export function ensureTypeId

(id: string, prefix: P): TypeId< return id as TypeId

} + +/** + * Canonical TypeID plus retired serialized forms of the same UUID. + * Use when comparing against text columns or JSON that may still store + * an alias prefix (e.g. `kb_article_…` after articles emit `article_…`). + */ +export function typeIdLookupKeys(id: string, prefix: IdPrefix): string[] { + const canonical = ensureTypeId(id, prefix) + const uuid = toUuid(canonical) + const aliases = Object.entries(ID_PREFIX_ALIASES) + .filter(([, mapped]) => mapped === prefix) + .map(([alias]) => TypeID.fromUUID(alias, uuid).toString()) + return [canonical, ...aliases] +} diff --git a/packages/ids/src/index.ts b/packages/ids/src/index.ts index 92fff3671f..ae952acaee 100644 --- a/packages/ids/src/index.ts +++ b/packages/ids/src/index.ts @@ -50,6 +50,7 @@ export { // Flexible handling normalizeToUuid, ensureTypeId, + typeIdLookupKeys, } from './core' // ============================================ From 159bba74d325a255f55c1491f72709aec3522fef Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 00:44:02 +0100 Subject: [PATCH 3/7] fix(ids): fold kb_article_ citations before the top-cited limit The Copilot report now rewrites retired prefixes in SQL so one article cited as both article_ and kb_article_ is ranked and capped as a single source. Co-authored-by: Cursor --- .../analytics/__tests__/copilot-usage.test.ts | 29 +++++++++++++++++++ .../server/domains/analytics/copilot-usage.ts | 7 +++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/web/src/lib/server/domains/analytics/__tests__/copilot-usage.test.ts b/apps/web/src/lib/server/domains/analytics/__tests__/copilot-usage.test.ts index 537a1747e6..4febaed0e6 100644 --- a/apps/web/src/lib/server/domains/analytics/__tests__/copilot-usage.test.ts +++ b/apps/web/src/lib/server/domains/analytics/__tests__/copilot-usage.test.ts @@ -487,6 +487,35 @@ describe.skipIf(!fixture.available)('getCopilotUsageMetrics (real DB)', () => { }) describe('topCitedSources', () => { + it('ranks an article cited under both prefixes above a single-prefix rival', async () => { + const popular = await seedArticle('Split across prefixes') + const rival = await seedArticle('Single prefix rival') + const legacy = `kb_article_${popular.slice('article_'.length)}` + await seedUsageLog('assistant', { + surface: 'copilot', + citedSources: [{ type: 'article', id: popular }], + }) + await seedUsageLog('assistant', { + surface: 'copilot', + citedSources: [{ type: 'article', id: legacy }], + }) + await seedUsageLog('assistant', { + surface: 'copilot', + citedSources: [{ type: 'article', id: rival }], + }) + + const metrics = await getCopilotUsageMetrics(FROM, TO) + expect(metrics.topCitedSources[0]).toMatchObject({ + id: popular, + title: 'Split across prefixes', + questions: 2, + }) + expect(metrics.topCitedSources[1]).toMatchObject({ + id: rival, + questions: 1, + }) + }) + it('joins historical kb_article_ citation ids to the live article_ row', async () => { const article = await seedArticle('Legacy citation') const legacy = `kb_article_${article.slice('article_'.length)}` diff --git a/apps/web/src/lib/server/domains/analytics/copilot-usage.ts b/apps/web/src/lib/server/domains/analytics/copilot-usage.ts index 35a83d8fd8..63cc2c2fce 100644 --- a/apps/web/src/lib/server/domains/analytics/copilot-usage.ts +++ b/apps/web/src/lib/server/domains/analytics/copilot-usage.ts @@ -410,7 +410,8 @@ export async function getCopilotUsageMetrics(from: Date, to: Date): Promise>'id' AS id, count(DISTINCT ai_usage_log.id)::int AS n + SELECT regexp_replace(elem->>'id', '^kb_article_', 'article_') AS id, + count(DISTINCT ai_usage_log.id)::int AS n FROM ai_usage_log CROSS JOIN LATERAL jsonb_array_elements( CASE WHEN jsonb_typeof(metadata->'citedSources') = 'array' @@ -423,8 +424,8 @@ export async function getCopilotUsageMetrics(from: Date, to: Date): Promise>'type' = 'article' AND created_at >= ${from.toISOString()} AND created_at < ${to.toISOString()} - GROUP BY elem->>'id' - ORDER BY count(DISTINCT ai_usage_log.id) DESC, elem->>'id' ASC + GROUP BY 1 + ORDER BY 2 DESC, 1 ASC LIMIT ${TOP_CITED_SOURCES_LIMIT} `) as unknown as Promise>, ]) From 0dd1386ef6600782ea906a1bbfe9b172b3c3ea82 Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 00:57:32 +0100 Subject: [PATCH 4/7] fix(ids): canonicalize article TypeIDs and batch redirect-rule labels typeIdSchema now rewrites kb_article_ to article_ so writers cannot persist the retired prefix, and redirect-rule lists load labels in two queries instead of one per row. Co-authored-by: Cursor --- ...help-center-redirect-rules.service.test.ts | 56 +++++++++---- .../help-center-redirect-rules.service.ts | 78 +++++++++++-------- .../src/lib/server/functions/help-center.ts | 4 +- .../widget/__tests__/article-ref.test.ts | 8 +- apps/web/src/lib/shared/widget/article-ref.ts | 2 +- packages/ids/src/__tests__/zod.test.ts | 4 +- packages/ids/src/zod.ts | 29 ++++--- 7 files changed, 113 insertions(+), 68 deletions(-) diff --git a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts index e10b879034..72e44e7bd3 100644 --- a/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts +++ b/apps/web/src/lib/server/domains/help-center/__tests__/help-center-redirect-rules.service.test.ts @@ -171,23 +171,49 @@ describe('createRedirectRule', () => { }) describe('listRedirectRules', () => { - it('resolves target labels for each rule', async () => { - mockSelectFrom.mockReturnValue({ - orderBy: vi.fn().mockResolvedValue([ - { - id: 'hc_redirect_rule_1' as HcRedirectRuleId, - path: '/old', - targetType: 'article', - targetId: 'article_1', - createdAt: new Date('2026-01-01'), - }, - ]), - }) - mockArticleFindFirst.mockResolvedValue({ title: 'Getting started' }) + it('resolves target labels in two batched lookups, not per row', async () => { + mockSelectFrom + .mockReturnValueOnce({ + orderBy: vi.fn().mockResolvedValue([ + { + id: 'hc_redirect_rule_1' as HcRedirectRuleId, + path: '/old', + targetType: 'article', + targetId: 'article_1', + createdAt: new Date('2026-01-01'), + }, + { + id: 'hc_redirect_rule_2' as HcRedirectRuleId, + path: '/older', + targetType: 'article', + targetId: 'article_2', + createdAt: new Date('2026-01-02'), + }, + { + id: 'hc_redirect_rule_3' as HcRedirectRuleId, + path: '/old-cat', + targetType: 'category', + targetId: 'kb_category_1', + createdAt: new Date('2026-01-03'), + }, + ]), + }) + .mockReturnValueOnce({ + where: vi.fn().mockResolvedValue([ + { id: 'article_1', title: 'Getting started' }, + { id: 'article_2', title: 'Billing' }, + ]), + }) + .mockReturnValueOnce({ + where: vi.fn().mockResolvedValue([{ id: 'kb_category_1', name: 'Guides' }]), + }) const rules = await listRedirectRules() - expect(rules).toHaveLength(1) - expect(rules[0].targetLabel).toBe('Getting started') + expect(rules).toHaveLength(3) + expect(rules.map((rule) => rule.targetLabel)).toEqual(['Getting started', 'Billing', 'Guides']) + expect(mockSelectFrom).toHaveBeenCalledTimes(3) + expect(mockArticleFindFirst).not.toHaveBeenCalled() + expect(mockCategoryFindFirst).not.toHaveBeenCalled() }) }) diff --git a/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts b/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts index ea1c8a5da8..ea59f5c6ef 100644 --- a/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts +++ b/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts @@ -108,45 +108,57 @@ async function requirePublishedTarget( return category.name } -/** Best-effort label lookup for the settings-card list; null if the target vanished. */ -async function lookupTargetLabel( - targetType: RedirectTargetType, - targetId: string -): Promise { - if (targetType === 'article') { - const article = await db.query.helpCenterArticles.findFirst({ - where: eq(helpCenterArticles.id, canonicalArticleTargetId(targetId)), - columns: { title: true }, - }) - return article?.title ?? null - } - const category = await db.query.helpCenterCategories.findFirst({ - where: eq(helpCenterCategories.id, targetId as KbCategoryId), - columns: { name: true }, - }) - return category?.name ?? null -} - export async function listRedirectRules(): Promise { const rows = await db .select() .from(helpCenterRedirectRules) .orderBy(desc(helpCenterRedirectRules.createdAt)) - const withLabels = await Promise.all( - rows.map(async (row) => { - const targetLabel = await lookupTargetLabel(row.targetType, row.targetId).catch(() => null) - return { - id: row.id, - path: row.path, - targetType: row.targetType, - targetId: row.targetId, - targetLabel, - createdAt: row.createdAt, - } - }) - ) - return withLabels + const articleIds = [ + ...new Set( + rows + .filter((row) => row.targetType === 'article') + .map((row) => canonicalArticleTargetId(row.targetId)) + ), + ] + const categoryIds = [ + ...new Set( + rows.filter((row) => row.targetType === 'category').map((row) => row.targetId as KbCategoryId) + ), + ] + + const [articles, categories] = await Promise.all([ + articleIds.length === 0 + ? Promise.resolve([] as Array<{ id: KbArticleId; title: string }>) + : db + .select({ id: helpCenterArticles.id, title: helpCenterArticles.title }) + .from(helpCenterArticles) + .where(inArray(helpCenterArticles.id, articleIds)), + categoryIds.length === 0 + ? Promise.resolve([] as Array<{ id: KbCategoryId; name: string }>) + : db + .select({ id: helpCenterCategories.id, name: helpCenterCategories.name }) + .from(helpCenterCategories) + .where(inArray(helpCenterCategories.id, categoryIds)), + ]) + + const articleTitleById = new Map(articles.map((article) => [article.id, article.title])) + const categoryNameById = new Map(categories.map((category) => [category.id, category.name])) + + return rows.map((row) => { + const targetLabel = + row.targetType === 'article' + ? (articleTitleById.get(canonicalArticleTargetId(row.targetId)) ?? null) + : (categoryNameById.get(row.targetId as KbCategoryId) ?? null) + return { + id: row.id, + path: row.path, + targetType: row.targetType, + targetId: row.targetId, + targetLabel, + createdAt: row.createdAt, + } + }) } export async function createRedirectRule( diff --git a/apps/web/src/lib/server/functions/help-center.ts b/apps/web/src/lib/server/functions/help-center.ts index d9b9e38e69..dac530b42c 100644 --- a/apps/web/src/lib/server/functions/help-center.ts +++ b/apps/web/src/lib/server/functions/help-center.ts @@ -593,7 +593,7 @@ export const searchPublicArticlesFn = createServerFn({ method: 'GET' }) export const resolvePublicArticleRefFn = createServerFn({ method: 'GET' }) .validator(z.object({ ref: z.string().min(1), locale: z.string().optional() })) .handler(async ({ data }) => { - const { articleTypeIdToKbArticleId } = await import('@/lib/shared/widget/article-ref') + const { canonicalArticleTypeId } = await import('@/lib/shared/widget/article-ref') const { getPublicArticleByIdForLocale, getPublicArticleBySlugForLocale } = await import('@/lib/server/domains/help-center/help-center-locale.query') const { DEFAULT_LOCALE } = await import('@/lib/shared/i18n') @@ -602,7 +602,7 @@ export const resolvePublicArticleRefFn = createServerFn({ method: 'GET' }) const viewer = await publicViewer() const locale = data.locale ?? DEFAULT_LOCALE try { - const kbId = articleTypeIdToKbArticleId(data.ref) + const kbId = canonicalArticleTypeId(data.ref) const load = (loc: string) => kbId ? getPublicArticleByIdForLocale(kbId, loc, viewer) diff --git a/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts b/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts index eabcbf5dcb..8793230726 100644 --- a/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts +++ b/apps/web/src/lib/shared/widget/__tests__/article-ref.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { generateId } from '@quackback/ids' -import { articleTypeIdToKbArticleId, isArticleTypeId } from '../article-ref' +import { canonicalArticleTypeId, isArticleTypeId } from '../article-ref' describe('article TypeID refs', () => { it('treats article_ and kb_article_ as the same row', () => { @@ -8,13 +8,13 @@ describe('article TypeID refs', () => { const legacy = `kb_article_${canonical.slice('article_'.length)}` expect(isArticleTypeId(canonical)).toBe(true) expect(isArticleTypeId(legacy)).toBe(true) - expect(articleTypeIdToKbArticleId(legacy)).toBe(canonical) - expect(articleTypeIdToKbArticleId(canonical)).toBe(canonical) + expect(canonicalArticleTypeId(legacy)).toBe(canonical) + expect(canonicalArticleTypeId(canonical)).toBe(canonical) }) it('rejects slugs and the old art_ prefix', () => { expect(isArticleTypeId('pricing')).toBe(false) expect(isArticleTypeId('art_01h...')).toBe(false) - expect(articleTypeIdToKbArticleId('pricing')).toBeNull() + expect(canonicalArticleTypeId('pricing')).toBeNull() }) }) diff --git a/apps/web/src/lib/shared/widget/article-ref.ts b/apps/web/src/lib/shared/widget/article-ref.ts index 398444219b..07690cce26 100644 --- a/apps/web/src/lib/shared/widget/article-ref.ts +++ b/apps/web/src/lib/shared/widget/article-ref.ts @@ -12,7 +12,7 @@ export function isArticleTypeId(ref: string): boolean { * Canonical `article_…` TypeID for a public or stored article ref. * Same UUID either way — so a retired `kb_article_…` id looks up the row. */ -export function articleTypeIdToKbArticleId(ref: string): ArticleId | null { +export function canonicalArticleTypeId(ref: string): ArticleId | null { if (!isValidTypeId(ref, 'article')) return null return ensureTypeId(ref, 'article') } diff --git a/packages/ids/src/__tests__/zod.test.ts b/packages/ids/src/__tests__/zod.test.ts index 95ae7fd6db..ed26b1dfec 100644 --- a/packages/ids/src/__tests__/zod.test.ts +++ b/packages/ids/src/__tests__/zod.test.ts @@ -45,12 +45,12 @@ describe('Zod TypeID Schemas', () => { expect(() => schema.parse('post_invalid')).toThrow() }) - it('accepts the retired kb_article_ alias for article ids', () => { + it('rewrites the retired kb_article_ alias to the canonical article_ prefix', () => { const schema = typeIdSchema('article') const canonical = generateId('article') const legacy = `kb_article_${canonical.slice('article_'.length)}` expect(schema.parse(canonical)).toBe(canonical) - expect(schema.parse(legacy)).toBe(legacy) + expect(schema.parse(legacy)).toBe(canonical) }) }) diff --git a/packages/ids/src/zod.ts b/packages/ids/src/zod.ts index 74bfc9bc15..e852f22354 100644 --- a/packages/ids/src/zod.ts +++ b/packages/ids/src/zod.ts @@ -34,17 +34,24 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 export function typeIdSchema

(prefix: P) { // Simplified for TanStack Start compatibility // Returns ZodEffects without branded types for better type inference - return z.string().refine( - (val) => { - try { - const tid = TypeID.fromString(val) - return prefixMatches(tid.getType(), prefix) - } catch { - return false - } - }, - { message: `Invalid ${prefix} ID format. Expected: ${prefix}_` } - ) + return z + .string() + .refine( + (val) => { + try { + const tid = TypeID.fromString(val) + return prefixMatches(tid.getType(), prefix) + } catch { + return false + } + }, + { message: `Invalid ${prefix} ID format. Expected: ${prefix}_` } + ) + .transform((val) => { + const tid = TypeID.fromString(val) + if (tid.getType() === prefix) return val + return TypeID.fromUUID(prefix, tid.toUUID()).toString() + }) } // ============================================ From 60ccffafb2d37d64577aa818b53124e552c79158 Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 00:58:11 +0100 Subject: [PATCH 5/7] fix(ids): keep isTypeId exact so alias narrowing stays sound isValidTypeId still accepts kb_article_ as an inbound article id; the TypeId<'article'> guard only matches the canonical prefix. Co-authored-by: Cursor --- packages/ids/src/__tests__/core.test.ts | 3 +++ packages/ids/src/core.ts | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/ids/src/__tests__/core.test.ts b/packages/ids/src/__tests__/core.test.ts index 47b3c62a5b..21ed21f475 100644 --- a/packages/ids/src/__tests__/core.test.ts +++ b/packages/ids/src/__tests__/core.test.ts @@ -7,6 +7,7 @@ import { parseTypeId, getTypeIdPrefix, isValidTypeId, + isTypeId, isUuid, isTypeIdFormat, batchFromUuid, @@ -288,6 +289,8 @@ describe('TypeID Core', () => { const legacy = `kb_article_${canonical.slice('article_'.length)}` expect(isValidTypeId(legacy, 'article')).toBe(true) expect(isValidTypeId(canonical, 'article')).toBe(true) + expect(isTypeId(legacy, 'article')).toBe(false) + expect(isTypeId(canonical, 'article')).toBe(true) expect(toUuid(legacy)).toBe(toUuid(canonical)) expect(ensureTypeId(legacy, 'article')).toBe(canonical) expect(normalizeToUuid(legacy, 'article')).toBe(toUuid(canonical)) diff --git a/packages/ids/src/core.ts b/packages/ids/src/core.ts index c6f3a1a57c..4e6315ab38 100644 --- a/packages/ids/src/core.ts +++ b/packages/ids/src/core.ts @@ -165,10 +165,19 @@ export function isValidTypeId(value: string, expectedPrefix?: IdPrefix): boolean } /** - * Type guard for checking if a string is a valid TypeID with specific prefix + * Type guard for a TypeID whose serialized prefix is exactly `prefix`. + * Retired aliases (`kb_article_…`) are valid inbound article ids via + * `isValidTypeId` / `ensureTypeId`, but they are not `TypeId<'article'>`. */ export function isTypeId

(value: string, prefix: P): value is TypeId

{ - return isValidTypeId(value, prefix) + try { + const tid = TypeID.fromString(value) + if (tid.getType() !== prefix) return false + tid.toUUID() + return true + } catch { + return false + } } /** From 5c5eff3ed72e322f9f8393d617e77436e8dd8f66 Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 01:04:14 +0100 Subject: [PATCH 6/7] refactor(ids): reuse core TypeID helpers in schema and isTypeId typeIdSchema now validates and rewrites through isValidTypeId/ensureTypeId, and isTypeId composes those checks instead of re-parsing. Co-authored-by: Cursor --- .../help-center-redirect-rules.service.ts | 26 +++++++---------- packages/ids/src/core.ts | 9 +----- packages/ids/src/zod.ts | 29 +++++++------------ 3 files changed, 22 insertions(+), 42 deletions(-) diff --git a/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts b/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts index ea59f5c6ef..0454688834 100644 --- a/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts +++ b/apps/web/src/lib/server/domains/help-center/help-center-redirect-rules.service.ts @@ -114,32 +114,26 @@ export async function listRedirectRules(): Promise { .from(helpCenterRedirectRules) .orderBy(desc(helpCenterRedirectRules.createdAt)) - const articleIds = [ - ...new Set( - rows - .filter((row) => row.targetType === 'article') - .map((row) => canonicalArticleTargetId(row.targetId)) - ), - ] - const categoryIds = [ - ...new Set( - rows.filter((row) => row.targetType === 'category').map((row) => row.targetId as KbCategoryId) - ), - ] + const articleIds = new Set() + const categoryIds = new Set() + for (const row of rows) { + if (row.targetType === 'article') articleIds.add(canonicalArticleTargetId(row.targetId)) + else categoryIds.add(row.targetId as KbCategoryId) + } const [articles, categories] = await Promise.all([ - articleIds.length === 0 + articleIds.size === 0 ? Promise.resolve([] as Array<{ id: KbArticleId; title: string }>) : db .select({ id: helpCenterArticles.id, title: helpCenterArticles.title }) .from(helpCenterArticles) - .where(inArray(helpCenterArticles.id, articleIds)), - categoryIds.length === 0 + .where(inArray(helpCenterArticles.id, [...articleIds])), + categoryIds.size === 0 ? Promise.resolve([] as Array<{ id: KbCategoryId; name: string }>) : db .select({ id: helpCenterCategories.id, name: helpCenterCategories.name }) .from(helpCenterCategories) - .where(inArray(helpCenterCategories.id, categoryIds)), + .where(inArray(helpCenterCategories.id, [...categoryIds])), ]) const articleTitleById = new Map(articles.map((article) => [article.id, article.title])) diff --git a/packages/ids/src/core.ts b/packages/ids/src/core.ts index 4e6315ab38..94f04a2469 100644 --- a/packages/ids/src/core.ts +++ b/packages/ids/src/core.ts @@ -170,14 +170,7 @@ export function isValidTypeId(value: string, expectedPrefix?: IdPrefix): boolean * `isValidTypeId` / `ensureTypeId`, but they are not `TypeId<'article'>`. */ export function isTypeId

(value: string, prefix: P): value is TypeId

{ - try { - const tid = TypeID.fromString(value) - if (tid.getType() !== prefix) return false - tid.toUUID() - return true - } catch { - return false - } + return isValidTypeId(value) && getTypeIdPrefix(value) === prefix } /** diff --git a/packages/ids/src/zod.ts b/packages/ids/src/zod.ts index e852f22354..37b83ed80c 100644 --- a/packages/ids/src/zod.ts +++ b/packages/ids/src/zod.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import { TypeID } from 'typeid-js' import { ID_PREFIXES, prefixMatches, type IdPrefix } from './prefixes' +import { ensureTypeId, isValidTypeId } from './core' import type { TypeId } from './types' /** @@ -34,24 +35,16 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 export function typeIdSchema

(prefix: P) { // Simplified for TanStack Start compatibility // Returns ZodEffects without branded types for better type inference - return z - .string() - .refine( - (val) => { - try { - const tid = TypeID.fromString(val) - return prefixMatches(tid.getType(), prefix) - } catch { - return false - } - }, - { message: `Invalid ${prefix} ID format. Expected: ${prefix}_` } - ) - .transform((val) => { - const tid = TypeID.fromString(val) - if (tid.getType() === prefix) return val - return TypeID.fromUUID(prefix, tid.toUUID()).toString() - }) + return z.string().transform((val, ctx) => { + if (!isValidTypeId(val, prefix)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid ${prefix} ID format. Expected: ${prefix}_`, + }) + return z.NEVER + } + return ensureTypeId(val, prefix) + }) } // ============================================ From b39fdbbbfef3f4e3a48820f0980d421b07f408bb Mon Sep 17 00:00:00 2001 From: James Morton Date: Thu, 10 Sep 2026 08:25:55 +0100 Subject: [PATCH 7/7] fix(help-center): canonicalize legacy admin article route IDs Bookmarked kb_article_ editor URLs now redirect to article_, and the detail query key is the same either way so publish/update cache the active editor instead of a stale alias key. Co-authored-by: Cursor --- .../__tests__/help-center-keys.test.ts | 17 +++++++++ .../web/src/lib/client/queries/help-center.ts | 4 ++- .../help-center-article-id-redirect.test.ts | 36 +++++++++++++++++++ .../admin/help-center.articles.$articleId.tsx | 13 ++++++- 4 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/client/queries/__tests__/help-center-keys.test.ts create mode 100644 apps/web/src/routes/admin/__tests__/help-center-article-id-redirect.test.ts diff --git a/apps/web/src/lib/client/queries/__tests__/help-center-keys.test.ts b/apps/web/src/lib/client/queries/__tests__/help-center-keys.test.ts new file mode 100644 index 0000000000..05d38ac1fb --- /dev/null +++ b/apps/web/src/lib/client/queries/__tests__/help-center-keys.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { generateId } from '@quackback/ids' +import { helpCenterKeys } from '../help-center' + +describe('helpCenterKeys.articleDetail', () => { + it('uses the same cache key for article_ and retired kb_article_ ids', () => { + const canonical = generateId('article') + const legacy = `kb_article_${canonical.slice('article_'.length)}` as typeof canonical + expect(helpCenterKeys.articleDetail(legacy)).toEqual(helpCenterKeys.articleDetail(canonical)) + expect(helpCenterKeys.articleDetail(canonical)).toEqual([ + 'help-center', + 'articles', + 'detail', + canonical, + ]) + }) +}) diff --git a/apps/web/src/lib/client/queries/help-center.ts b/apps/web/src/lib/client/queries/help-center.ts index f3d26d0666..9d9081432c 100644 --- a/apps/web/src/lib/client/queries/help-center.ts +++ b/apps/web/src/lib/client/queries/help-center.ts @@ -6,6 +6,7 @@ import { queryOptions, infiniteQueryOptions, keepPreviousData } from '@tanstack/react-query' import type { KbArticleId } from '@quackback/ids' +import { canonicalArticleTypeId } from '@/lib/shared/widget/article-ref' import { listCategoriesFn, listPublicCategoriesFn, @@ -39,7 +40,8 @@ export const helpCenterKeys = { articlePerformance: () => [...helpCenterKeys.articles(), 'performance'] as const, searchTerms: () => [...helpCenterKeys.all, 'search-terms'] as const, articleDetails: () => [...helpCenterKeys.articles(), 'detail'] as const, - articleDetail: (id: KbArticleId) => [...helpCenterKeys.articleDetails(), id] as const, + articleDetail: (id: KbArticleId) => + [...helpCenterKeys.articleDetails(), canonicalArticleTypeId(id) ?? id] as const, articleFeedbackReasons: (id: KbArticleId) => [...helpCenterKeys.articleDetail(id), 'feedback-reasons'] as const, public: () => [...helpCenterKeys.all, 'public'] as const, diff --git a/apps/web/src/routes/admin/__tests__/help-center-article-id-redirect.test.ts b/apps/web/src/routes/admin/__tests__/help-center-article-id-redirect.test.ts new file mode 100644 index 0000000000..483565707e --- /dev/null +++ b/apps/web/src/routes/admin/__tests__/help-center-article-id-redirect.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { generateId } from '@quackback/ids' + +const { Route } = await import('../help-center.articles.$articleId') + +type BeforeLoadFn = (ctx: { params: { articleId: string } }) => void + +const beforeLoad = Route.options.beforeLoad as BeforeLoadFn + +function catchRedirect(fn: () => void): Record { + let thrown: unknown + try { + fn() + } catch (e) { + thrown = e + } + expect(thrown).toBeInstanceOf(Response) + // oxlint-disable-next-line @typescript-eslint/no-explicit-any + return (thrown as any).options as Record +} + +describe('admin article editor legacy TypeID redirect', () => { + it('replaces a bookmarked kb_article_ URL with article_', () => { + const canonical = generateId('article') + const legacy = `kb_article_${canonical.slice('article_'.length)}` + const opts = catchRedirect(() => beforeLoad({ params: { articleId: legacy } })) + expect(opts.to).toBe('/admin/help-center/articles/$articleId') + expect(opts.params).toEqual({ articleId: canonical }) + expect(opts.replace).toBe(true) + }) + + it('leaves a canonical article_ URL alone', () => { + const canonical = generateId('article') + expect(beforeLoad({ params: { articleId: canonical } })).toBeUndefined() + }) +}) diff --git a/apps/web/src/routes/admin/help-center.articles.$articleId.tsx b/apps/web/src/routes/admin/help-center.articles.$articleId.tsx index d5ea8edb9a..8bf793ff43 100644 --- a/apps/web/src/routes/admin/help-center.articles.$articleId.tsx +++ b/apps/web/src/routes/admin/help-center.articles.$articleId.tsx @@ -1,10 +1,21 @@ -import { createFileRoute, Navigate } from '@tanstack/react-router' +import { createFileRoute, Navigate, redirect } from '@tanstack/react-router' import { HelpCenterArticleEditor } from '@/components/admin/help-center/help-center-article-editor' import { helpCenterQueries } from '@/lib/client/queries/help-center' +import { canonicalArticleTypeId } from '@/lib/shared/widget/article-ref' import type { FeatureFlags } from '@/lib/shared/types/settings' import type { KbArticleId } from '@quackback/ids' export const Route = createFileRoute('/admin/help-center/articles/$articleId')({ + beforeLoad: ({ params }) => { + const canonical = canonicalArticleTypeId(params.articleId) + if (canonical && canonical !== params.articleId) { + throw redirect({ + to: '/admin/help-center/articles/$articleId', + params: { articleId: canonical }, + replace: true, + }) + } + }, loader: async ({ context, params }) => { const { queryClient } = context // Warm the queries the editor reads so the form renders with real data on