Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
7012aa6
feat(ids): serialize help-center articles as article_
mortondev Sep 9, 2026
66b8ce2
merge fix/widget-new-post-open into feat/article-typeid-prefix
mortondev Sep 9, 2026
b1a091d
fix(ids): match legacy kb_article_ text in citations and redirects
mortondev Sep 9, 2026
159bba7
fix(ids): fold kb_article_ citations before the top-cited limit
mortondev Sep 9, 2026
3bcf174
Merge remote-tracking branch 'origin/fix/widget-new-post-open' into f…
mortondev Sep 9, 2026
0dd1386
fix(ids): canonicalize article TypeIDs and batch redirect-rule labels
mortondev Sep 9, 2026
60ccffa
fix(ids): keep isTypeId exact so alias narrowing stays sound
mortondev Sep 9, 2026
3da8e1d
Merge remote-tracking branch 'origin/fix/widget-new-post-open' into f…
mortondev Sep 10, 2026
5c5eff3
refactor(ids): reuse core TypeID helpers in schema and isTypeId
mortondev Sep 10, 2026
f3e755a
merge(widget): bring article TypeID prefix onto latest open() identit…
mortondev Sep 10, 2026
ac6c5e9
merge(widget): bring article TypeID prefix onto latest open() review …
mortondev Sep 10, 2026
546f9da
merge(widget): bring article TypeID prefix onto latest open() abort/r…
mortondev Sep 10, 2026
2edf4ca
merge(widget): bring article TypeID prefix onto latest Ask AI review …
mortondev Sep 10, 2026
ea909e9
merge(widget): bring article TypeID prefix onto Ask AI typecheck fix
mortondev Sep 10, 2026
b39fdbb
fix(help-center): canonicalize legacy admin article route IDs
mortondev Sep 10, 2026
8df31fd
merge(widget): bring article TypeID prefix onto latest identity revie…
mortondev Sep 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function renderDialog() {
return render(
<QueryClientProvider client={queryClient}>
<ArticleFeedbackReasonsDialog
articleId={'kb_article_1' as KbArticleId}
articleId={'article_1' as KbArticleId}
open
onOpenChange={() => {}}
/>
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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(<ArticlePerformanceTable />)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ afterEach(() => {
})

const META: AskAiSourceMeta = {
articleId: 'kb_article_1',
articleId: 'article_1',
urlId: 1,
title: 'Refund policy',
slug: 'refund-policy',
Expand All @@ -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({
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -149,7 +149,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 })
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ afterEach(() => {
function renderFeedback() {
return render(
<IntlProvider locale="en" messages={{}} onError={() => {}}>
<HelpCenterArticleFeedback articleId="kb_article_1" />
<HelpCenterArticleFeedback articleId="article_1" />
</IntlProvider>
)
}
Expand Down
10 changes: 5 additions & 5 deletions apps/web/src/components/widget/__tests__/widget-compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,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)
})
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/widget/widget-compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/lib/client/queries/__tests__/help-center-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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,
])
})
})
4 changes: 3 additions & 1 deletion apps/web/src/lib/client/queries/help-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,55 @@ 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)}`
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')
Expand Down
51 changes: 37 additions & 14 deletions apps/web/src/lib/server/domains/analytics/copilot-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<KbArticleId, number>()
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
Expand Down Expand Up @@ -392,7 +410,8 @@ export async function getCopilotUsageMetrics(from: Date, to: Date): Promise<Copi
// both a content owner and a stable admin fix-it URL (see
// CopilotUsageMetrics.topCitedSources).
db.execute(sql`
SELECT elem->>'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'
Expand All @@ -405,8 +424,8 @@ export async function getCopilotUsageMetrics(from: Date, to: Date): Promise<Copi
AND elem->>'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<Array<{ id: string; n: number }>>,
])
Expand All @@ -424,7 +443,11 @@ export async function getCopilotUsageMetrics(from: Date, to: Date): Promise<Copi
// Title/url are resolved live off the articles table rather than carried in
// ai_usage_log metadata, so a rename shows up immediately and a deleted
// article drops out of the report instead of linking nowhere.
const citedArticleIds = citedSourceRows.map((row) => 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()]
Comment thread
mortondev marked this conversation as resolved.
const citedLookupKeys = citedArticleIds.flatMap((id) => typeIdLookupKeys(id, 'article'))
const [articles, sourceInsertRows] = await Promise.all([
citedArticleIds.length
? db
Expand Down Expand Up @@ -455,26 +478,26 @@ export async function getCopilotUsageMetrics(from: Date, to: Date): Promise<Copi
AND created_at >= ${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
`) as unknown as Promise<Array<{ id: string; n: number }>>)
: Promise.resolve([]),
])
const articleTitleById = new Map(articles.map((a) => [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),
}
})

Expand Down
Loading
Loading