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:
2026-06-17 17:06:26 +05:30
parent f61c070428
commit 5801cbf85b
13 changed files with 479 additions and 1 deletions
+2
View File
@@ -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,
}));
}
}
+7
View File
@@ -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);
+2 -1
View File
@@ -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],
})