feat: member portal Sprint 10 — Knowledge Base
- KnowledgeBoard + KnowledgeItem models + migration (DRAFT/PUBLISHED/ARCHIVED status, slug per tenant) - KnowledgeModule: admin CRUD for boards + items (auto-slug, status workflow) - Member endpoint GET /my/knowledge?q= — published items grouped by board, optional text search over question/answer - /my/knowledge page: search box + collapsible boards with accordion FAQ items - KnowledgeItem accordion + KnowledgeSearch (debounced URL-driven) client components - Knowledge nav item; register KnowledgeModule Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'board';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KnowledgeService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// ── Admin: boards ──────────────────────────────────────────────────────────
|
||||
|
||||
async listBoardsAdmin(tenantId: string) {
|
||||
return this.prisma.knowledgeBoard.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
include: { _count: { select: { items: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async createBoard(tenantId: string, dto: { name: string; description?: string; sortOrder?: number }) {
|
||||
let slug = slugify(dto.name);
|
||||
const existing = await this.prisma.knowledgeBoard.findFirst({ where: { tenantId, slug } });
|
||||
if (existing) slug = `${slug}-${Date.now().toString(36)}`;
|
||||
|
||||
return this.prisma.knowledgeBoard.create({
|
||||
data: {
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
description: dto.description ?? null,
|
||||
slug,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateBoard(tenantId: string, id: string, dto: { name?: string; description?: string; sortOrder?: number }) {
|
||||
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id, tenantId } });
|
||||
if (!board) throw new NotFoundException('Board not found');
|
||||
return this.prisma.knowledgeBoard.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined && { name: dto.name }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeBoard(tenantId: string, id: string) {
|
||||
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id, tenantId } });
|
||||
if (!board) throw new NotFoundException('Board not found');
|
||||
await this.prisma.knowledgeBoard.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Admin: items ─────────────────────────────────────────────────────────────
|
||||
|
||||
async listItemsAdmin(tenantId: string, boardId: string) {
|
||||
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id: boardId, tenantId } });
|
||||
if (!board) throw new NotFoundException('Board not found');
|
||||
return this.prisma.knowledgeItem.findMany({
|
||||
where: { boardId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async createItem(tenantId: string, adminId: string, boardId: string, dto: {
|
||||
question: string;
|
||||
answer: string;
|
||||
tags?: string[];
|
||||
status?: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
const board = await this.prisma.knowledgeBoard.findFirst({ where: { id: boardId, tenantId } });
|
||||
if (!board) throw new NotFoundException('Board not found');
|
||||
return this.prisma.knowledgeItem.create({
|
||||
data: {
|
||||
boardId,
|
||||
tenantId,
|
||||
question: dto.question,
|
||||
answer: dto.answer,
|
||||
tags: dto.tags ?? [],
|
||||
status: dto.status ?? 'DRAFT',
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
createdBy: adminId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateItem(tenantId: string, itemId: string, dto: {
|
||||
question?: string;
|
||||
answer?: string;
|
||||
tags?: string[];
|
||||
status?: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
const item = await this.prisma.knowledgeItem.findFirst({ where: { id: itemId, tenantId } });
|
||||
if (!item) throw new NotFoundException('Item not found');
|
||||
return this.prisma.knowledgeItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
...(dto.question !== undefined && { question: dto.question }),
|
||||
...(dto.answer !== undefined && { answer: dto.answer }),
|
||||
...(dto.tags !== undefined && { tags: dto.tags }),
|
||||
...(dto.status !== undefined && { status: dto.status }),
|
||||
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeItem(tenantId: string, itemId: string) {
|
||||
const item = await this.prisma.knowledgeItem.findFirst({ where: { id: itemId, tenantId } });
|
||||
if (!item) throw new NotFoundException('Item not found');
|
||||
await this.prisma.knowledgeItem.delete({ where: { id: itemId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Member: read published knowledge grouped by board ────────────────────────
|
||||
|
||||
async listForMember(tenantId: string, q?: string) {
|
||||
const boards = await this.prisma.knowledgeBoard.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
include: {
|
||||
items: {
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
...(q && q.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ question: { contains: q.trim(), mode: 'insensitive' } },
|
||||
{ answer: { contains: q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
select: { id: true, question: true, answer: true, tags: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return boards
|
||||
.filter((b) => b.items.length > 0)
|
||||
.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
items: b.items,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user