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:
@@ -21,6 +21,7 @@ import { EventsModule } from './modules/events/events.module';
|
||||
import { GamificationModule } from './modules/gamification/gamification.module';
|
||||
import { SevaModule } from './modules/seva/seva.module';
|
||||
import { CirclesModule } from './modules/circles/circles.module';
|
||||
import { KnowledgeModule } from './modules/knowledge/knowledge.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -46,6 +47,7 @@ import { CirclesModule } from './modules/circles/circles.module';
|
||||
GamificationModule,
|
||||
SevaModule,
|
||||
CirclesModule,
|
||||
KnowledgeModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { KnowledgeService } from './knowledge.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
type ItemStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||
|
||||
@Controller('admin/knowledge')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('OWNER', 'ADMIN')
|
||||
export class KnowledgeController {
|
||||
constructor(private readonly service: KnowledgeService) {}
|
||||
|
||||
@Get('boards')
|
||||
listBoards(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.service.listBoardsAdmin(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Post('boards')
|
||||
createBoard(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Body() body: { name: string; description?: string; sortOrder?: number },
|
||||
) {
|
||||
return this.service.createBoard(ctx.tenantId, body);
|
||||
}
|
||||
|
||||
@Patch('boards/:id')
|
||||
updateBoard(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { name?: string; description?: string; sortOrder?: number },
|
||||
) {
|
||||
return this.service.updateBoard(ctx.tenantId, id, body);
|
||||
}
|
||||
|
||||
@Delete('boards/:id')
|
||||
removeBoard(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.service.removeBoard(ctx.tenantId, id);
|
||||
}
|
||||
|
||||
@Get('boards/:id/items')
|
||||
listItems(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
|
||||
return this.service.listItemsAdmin(ctx.tenantId, id);
|
||||
}
|
||||
|
||||
@Post('boards/:id/items')
|
||||
createItem(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { question: string; answer: string; tags?: string[]; status?: ItemStatus; sortOrder?: number },
|
||||
) {
|
||||
return this.service.createItem(ctx.tenantId, ctx.adminId!, id, body);
|
||||
}
|
||||
|
||||
@Patch('items/:itemId')
|
||||
updateItem(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('itemId') itemId: string,
|
||||
@Body() body: { question?: string; answer?: string; tags?: string[]; status?: ItemStatus; sortOrder?: number },
|
||||
) {
|
||||
return this.service.updateItem(ctx.tenantId, itemId, body);
|
||||
}
|
||||
|
||||
@Delete('items/:itemId')
|
||||
removeItem(@CurrentTenantContext() ctx: TenantContext, @Param('itemId') itemId: string) {
|
||||
return this.service.removeItem(ctx.tenantId, itemId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { KnowledgeController } from './knowledge.controller';
|
||||
import { KnowledgeService } from './knowledge.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [KnowledgeController],
|
||||
providers: [KnowledgeService],
|
||||
exports: [KnowledgeService],
|
||||
})
|
||||
export class KnowledgeModule {}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { MyService } from './my.service';
|
||||
import { SevaService } from '../seva/seva.service';
|
||||
import { CirclesService } from '../circles/circles.service';
|
||||
import { AskAIService } from '../ask-ai/ask-ai.service';
|
||||
import { KnowledgeService } from '../knowledge/knowledge.service';
|
||||
import { MemberAuth } from '../auth/member-auth.decorator';
|
||||
import { CurrentMember } from '../auth/current-member.decorator';
|
||||
import type { MemberJwtPayload } from '@tower/types';
|
||||
@@ -30,8 +31,14 @@ export class MyController {
|
||||
private readonly seva: SevaService,
|
||||
private readonly circles: CirclesService,
|
||||
private readonly askAI: AskAIService,
|
||||
private readonly knowledge: KnowledgeService,
|
||||
) {}
|
||||
|
||||
@Get('knowledge')
|
||||
knowledgeList(@CurrentMember() member: MemberJwtPayload, @Query('q') q?: string) {
|
||||
return this.knowledge.listForMember(member.tenantId, q);
|
||||
}
|
||||
|
||||
@Get('ask')
|
||||
askHistory(@CurrentMember() member: MemberJwtPayload) {
|
||||
return this.askAI.history(member.sub, member.tenantId);
|
||||
|
||||
@@ -6,9 +6,10 @@ import { SevaModule } from '../seva/seva.module';
|
||||
import { GamificationModule } from '../gamification/gamification.module';
|
||||
import { CirclesModule } from '../circles/circles.module';
|
||||
import { AskAIModule } from '../ask-ai/ask-ai.module';
|
||||
import { KnowledgeModule } from '../knowledge/knowledge.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule],
|
||||
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule, KnowledgeModule],
|
||||
controllers: [MyController],
|
||||
providers: [MyService],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user