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
@@ -0,0 +1,37 @@
CREATE TYPE "KnowledgeItemStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
CREATE TABLE "KnowledgeBoard" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"slug" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KnowledgeBoard_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "KnowledgeItem" (
"id" TEXT NOT NULL,
"boardId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"question" TEXT NOT NULL,
"answer" TEXT NOT NULL,
"tags" TEXT[],
"status" "KnowledgeItemStatus" NOT NULL DEFAULT 'DRAFT',
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KnowledgeItem_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "KnowledgeBoard_tenantId_slug_key" ON "KnowledgeBoard"("tenantId", "slug");
CREATE INDEX "KnowledgeBoard_tenantId_idx" ON "KnowledgeBoard"("tenantId");
CREATE INDEX "KnowledgeItem_tenantId_status_idx" ON "KnowledgeItem"("tenantId", "status");
CREATE INDEX "KnowledgeItem_boardId_status_idx" ON "KnowledgeItem"("boardId", "status");
ALTER TABLE "KnowledgeBoard" ADD CONSTRAINT "KnowledgeBoard_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "KnowledgeItem" ADD CONSTRAINT "KnowledgeItem_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "KnowledgeBoard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "KnowledgeItem" ADD CONSTRAINT "KnowledgeItem_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+48
View File
@@ -96,6 +96,8 @@ model Tenant {
gamificationEvents GamificationEvent[] gamificationEvents GamificationEvent[]
circles Circle[] circles Circle[]
askAIQueries AskAIQuery[] askAIQueries AskAIQuery[]
knowledgeBoards KnowledgeBoard[]
knowledgeItems KnowledgeItem[]
} }
enum AdminRole { enum AdminRole {
@@ -751,3 +753,49 @@ model AskAIQuery {
@@index([tenantId, createdAt]) @@index([tenantId, createdAt])
@@index([userId, createdAt]) @@index([userId, createdAt])
} }
// ============================================================================
// Knowledge Base (admin-curated FAQ boards)
// ============================================================================
enum KnowledgeItemStatus {
DRAFT
PUBLISHED
ARCHIVED
}
model KnowledgeBoard {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
name String
description String?
slug String
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items KnowledgeItem[]
@@unique([tenantId, slug])
@@index([tenantId])
}
model KnowledgeItem {
id String @id @default(cuid())
boardId String
board KnowledgeBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
question String
answer String
tags String[]
status KnowledgeItemStatus @default(DRAFT)
sortOrder Int @default(0)
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, status])
@@index([boardId, status])
}
+2
View File
@@ -21,6 +21,7 @@ import { EventsModule } from './modules/events/events.module';
import { GamificationModule } from './modules/gamification/gamification.module'; import { GamificationModule } from './modules/gamification/gamification.module';
import { SevaModule } from './modules/seva/seva.module'; import { SevaModule } from './modules/seva/seva.module';
import { CirclesModule } from './modules/circles/circles.module'; import { CirclesModule } from './modules/circles/circles.module';
import { KnowledgeModule } from './modules/knowledge/knowledge.module';
@Module({ @Module({
imports: [ imports: [
@@ -46,6 +47,7 @@ import { CirclesModule } from './modules/circles/circles.module';
GamificationModule, GamificationModule,
SevaModule, SevaModule,
CirclesModule, CirclesModule,
KnowledgeModule,
], ],
}) })
export class AppModule {} 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 { SevaService } from '../seva/seva.service';
import { CirclesService } from '../circles/circles.service'; import { CirclesService } from '../circles/circles.service';
import { AskAIService } from '../ask-ai/ask-ai.service'; import { AskAIService } from '../ask-ai/ask-ai.service';
import { KnowledgeService } from '../knowledge/knowledge.service';
import { MemberAuth } from '../auth/member-auth.decorator'; import { MemberAuth } from '../auth/member-auth.decorator';
import { CurrentMember } from '../auth/current-member.decorator'; import { CurrentMember } from '../auth/current-member.decorator';
import type { MemberJwtPayload } from '@tower/types'; import type { MemberJwtPayload } from '@tower/types';
@@ -30,8 +31,14 @@ export class MyController {
private readonly seva: SevaService, private readonly seva: SevaService,
private readonly circles: CirclesService, private readonly circles: CirclesService,
private readonly askAI: AskAIService, 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') @Get('ask')
askHistory(@CurrentMember() member: MemberJwtPayload) { askHistory(@CurrentMember() member: MemberJwtPayload) {
return this.askAI.history(member.sub, member.tenantId); 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 { GamificationModule } from '../gamification/gamification.module';
import { CirclesModule } from '../circles/circles.module'; import { CirclesModule } from '../circles/circles.module';
import { AskAIModule } from '../ask-ai/ask-ai.module'; import { AskAIModule } from '../ask-ai/ask-ai.module';
import { KnowledgeModule } from '../knowledge/knowledge.module';
@Module({ @Module({
imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule], imports: [AuthModule, SevaModule, GamificationModule, CirclesModule, AskAIModule, KnowledgeModule],
controllers: [MyController], controllers: [MyController],
providers: [MyService], providers: [MyService],
}) })
+11
View File
@@ -0,0 +1,11 @@
import { jsonResponse, memberApiFetch } from '../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const qs = url.searchParams.toString();
const res = await memberApiFetch(`/my/knowledge${qs ? `?${qs}` : ''}`);
const body = await res.json();
return jsonResponse(body, res.status);
}
@@ -0,0 +1,38 @@
'use client';
import { useState } from 'react';
interface Props {
question: string;
answer: string;
tags: string[];
}
export function KnowledgeItem({ question, answer, tags }: Props) {
const [open, setOpen] = useState(false);
return (
<div className="border-b border-gray-100 last:border-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center justify-between gap-3 py-3.5 text-left"
>
<span className="text-sm font-medium text-gray-900">{question}</span>
<span className={`text-gray-400 transition-transform shrink-0 ${open ? 'rotate-180' : ''}`}></span>
</button>
{open && (
<div className="pb-4">
<p className="text-sm text-gray-600 whitespace-pre-line leading-relaxed">{answer}</p>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{tags.map((t) => (
<span key={t} className="text-[10px] bg-gray-100 text-gray-500 rounded-full px-2 py-0.5">{t}</span>
))}
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,30 @@
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
export function KnowledgeSearch() {
const router = useRouter();
const searchParams = useSearchParams();
const [q, setQ] = useState(searchParams.get('q') ?? '');
useEffect(() => {
const handle = setTimeout(() => {
const params = new URLSearchParams();
if (q.trim()) params.set('q', q.trim());
router.replace(`/my/knowledge${params.toString() ? `?${params.toString()}` : ''}`);
}, 300);
return () => clearTimeout(handle);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [q]);
return (
<input
type="text"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search the knowledge base…"
className="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { getMemberToken, getApiBaseUrl } from '../../_lib/api';
import { KnowledgeItem } from './KnowledgeItem';
import { KnowledgeSearch } from './KnowledgeSearch';
import Link from 'next/link';
interface Board {
id: string;
name: string;
description: string | null;
items: Array<{ id: string; question: string; answer: string; tags: string[] }>;
}
async function fetchKnowledge(token: string, q?: string): Promise<Board[]> {
const params = new URLSearchParams();
if (q) params.set('q', q);
const res = await fetch(`${getApiBaseUrl()}/my/knowledge${params.toString() ? `?${params.toString()}` : ''}`, {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
}).catch(() => null);
if (!res || !res.ok) return [];
return (await res.json()) as Board[];
}
export default async function KnowledgePage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const token = await getMemberToken();
if (!token) return null;
const { q } = await searchParams;
const boards = await fetchKnowledge(token, q);
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-900">Knowledge base</h1>
<Link href="/my" className="text-sm text-indigo-600 hover:underline"> Dashboard</Link>
</div>
<KnowledgeSearch />
{boards.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<p className="text-sm">{q ? 'No matching articles.' : 'No knowledge articles yet.'}</p>
{!q && <p className="text-xs mt-1">Your chapter admin will publish FAQs and guides here.</p>}
</div>
) : (
<div className="space-y-5">
{boards.map((b) => (
<section key={b.id} className="bg-white rounded-xl border border-gray-200 p-5">
<h2 className="text-sm font-semibold text-gray-900">{b.name}</h2>
{b.description && <p className="text-xs text-gray-400 mt-0.5 mb-1">{b.description}</p>}
<div className="mt-1">
{b.items.map((item) => (
<KnowledgeItem key={item.id} question={item.question} answer={item.answer} tags={item.tags} />
))}
</div>
</section>
))}
</div>
)}
</div>
);
}
+1
View File
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
const NAV = [ const NAV = [
{ href: '/my', label: 'Dashboard' }, { href: '/my', label: 'Dashboard' },
{ href: '/my/ask', label: 'Ask AI' }, { href: '/my/ask', label: 'Ask AI' },
{ href: '/my/knowledge', label: 'Knowledge' },
{ href: '/my/digest', label: 'Digest' }, { href: '/my/digest', label: 'Digest' },
{ href: '/my/events', label: 'Events' }, { href: '/my/events', label: 'Events' },
{ href: '/my/seva', label: 'Seva & Points' }, { href: '/my/seva', label: 'Seva & Points' },