feat(012-admin-list-views): add GET /admin/products/:id/knowledge for governance

Follow-up to 012-admin-list-views, discovered while building supporthub-
web's own knowledge-governance screen (001-agent-admin-ui User Story 7):
GET /knowledge/retrieve only ever returns published entries (its own
AI-consumption purpose), so a governance screen that needs to see and
publish a draft entry had no endpoint to list it. Adds a small
admin-list-views-style read query scoped to the knowledge module itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-07 16:35:25 +05:30
co-authored by Claude Sonnet 5
parent 3bd068b031
commit 7948182988
5 changed files with 63 additions and 0 deletions
@@ -20,6 +20,15 @@ export class KnowledgeController {
return reply.status(201).send({ success: true, data: entry, meta: null });
}
/** 012-admin-list-views follow-up: the governance screen's own data source (every status,
* unlike GET /knowledge/retrieve which is published-only). */
async listForGovernance(request: FastifyRequest, reply: FastifyReply) {
const { externalProductId } = request.params as { externalProductId: string };
const productId = await resolveProductId(externalProductId);
const entries = await this.service.listForGovernance(productId);
return reply.status(200).send({ success: true, data: entries, meta: null });
}
async publish(request: FastifyRequest, reply: FastifyReply) {
const { code } = request.params as { code: string };
const { effectiveDate } = publishKnowledgeEntrySchema.parse(request.body ?? {});
@@ -121,6 +121,16 @@ export class KnowledgeRepository {
});
}
/** 012-admin-list-views follow-up: every current-version entry for a product, any status —
* `retrieve` below only ever returns `published` entries (AI-consumption path), so the
* governance screen (which must see drafts to publish them) needs its own query. */
async findAllForProduct(productId: string): Promise<KnowledgeEntry[]> {
return this.prisma.knowledgeEntry.findMany({
where: { productId, isCurrentVersion: true },
orderBy: { createdAt: 'desc' },
});
}
/** research.md "Retrieval — structured filtering": filters apply before any ranking; ranking
* is validated-first, then most-recently-effective. */
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
@@ -14,6 +14,12 @@ export async function knowledgeRoutes(fastify: FastifyInstance): Promise<void> {
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
(req, reply) => knowledgeController.create(req, reply),
);
// 012-admin-list-views follow-up: the governance screen's own data source (every status).
fastify.get(
'/admin/products/:externalProductId/knowledge',
{ preHandler: fastify.authenticate },
(req, reply) => knowledgeController.listForGovernance(req, reply),
);
fastify.patch(
'/admin/knowledge/:code/publish',
{ preHandler: [fastify.authenticate, requireRole('ADMIN')] },
@@ -57,6 +57,12 @@ export class KnowledgeService {
async retrieve(filters: RetrieveFilters): Promise<KnowledgeEntry[]> {
return this.repo.retrieve(filters);
}
/** 012-admin-list-views follow-up: every entry for a product, any status — the governance
* screen's own data source (unlike `retrieve`, which is published-only). */
async listForGovernance(productId: string): Promise<KnowledgeEntry[]> {
return this.repo.findAllForProduct(productId);
}
}
export const knowledgeService = new KnowledgeService();
@@ -58,6 +58,38 @@ describe('Knowledge entry authoring, publishing, and versioning', () => {
expect(afterPublish.json().data.find((e: { code: string }) => e.code === code)).toBeDefined();
});
it('012-admin-list-views follow-up: the governance list shows a draft entry, unlike retrieve', async () => {
const draftCode = `KB-TEST-DRAFT-${Date.now()}`;
await app.inject({
method: 'POST',
url: `/admin/products/${externalProductId}/knowledge`,
headers: authHeader(token),
payload: { code: draftCode, type: 'faq', problem: 'Still a draft' },
});
const governanceList = await app.inject({
method: 'GET',
url: `/admin/products/${externalProductId}/knowledge`,
headers: authHeader(token),
});
expect(governanceList.statusCode).toBe(200);
const draftEntry = governanceList
.json()
.data.find((e: { code: string }) => e.code === draftCode);
expect(draftEntry).toBeDefined();
expect(draftEntry.status).toBe('draft');
const retrieveResult = await app.inject({
method: 'GET',
url: `/knowledge/retrieve?productId=${externalProductId}`,
});
expect(
retrieveResult.json().data.find((e: { code: string }) => e.code === draftCode),
).toBeUndefined();
await prismaClient.knowledgeEntry.deleteMany({ where: { code: draftCode } });
});
it('Scenario 2: editing creates a new version and preserves the prior one', async () => {
const editResponse = await app.inject({
method: 'PUT',