From 5801cbf85bab0c7d80055e63915b34f1357f0a58 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Wed, 17 Jun 2026 17:06:26 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20member=20portal=20Sprint=2010=20?= =?UTF-8?q?=E2=80=94=20Knowledge=20Base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../migration.sql | 37 +++++ apps/api/prisma/schema.prisma | 48 ++++++ apps/api/src/app.module.ts | 2 + .../modules/knowledge/knowledge.controller.ts | 71 ++++++++ .../src/modules/knowledge/knowledge.module.ts | 12 ++ .../modules/knowledge/knowledge.service.ts | 154 ++++++++++++++++++ apps/api/src/modules/my/my.controller.ts | 7 + apps/api/src/modules/my/my.module.ts | 3 +- apps/web/app/api/my/knowledge/route.ts | 11 ++ apps/web/app/my/knowledge/KnowledgeItem.tsx | 38 +++++ apps/web/app/my/knowledge/KnowledgeSearch.tsx | 30 ++++ apps/web/app/my/knowledge/page.tsx | 66 ++++++++ apps/web/app/my/layout.tsx | 1 + 13 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 apps/api/prisma/migrations/20260617250000_add_knowledge_base/migration.sql create mode 100644 apps/api/src/modules/knowledge/knowledge.controller.ts create mode 100644 apps/api/src/modules/knowledge/knowledge.module.ts create mode 100644 apps/api/src/modules/knowledge/knowledge.service.ts create mode 100644 apps/web/app/api/my/knowledge/route.ts create mode 100644 apps/web/app/my/knowledge/KnowledgeItem.tsx create mode 100644 apps/web/app/my/knowledge/KnowledgeSearch.tsx create mode 100644 apps/web/app/my/knowledge/page.tsx diff --git a/apps/api/prisma/migrations/20260617250000_add_knowledge_base/migration.sql b/apps/api/prisma/migrations/20260617250000_add_knowledge_base/migration.sql new file mode 100644 index 0000000..6aaf679 --- /dev/null +++ b/apps/api/prisma/migrations/20260617250000_add_knowledge_base/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index de940d6..1de23ca 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -96,6 +96,8 @@ model Tenant { gamificationEvents GamificationEvent[] circles Circle[] askAIQueries AskAIQuery[] + knowledgeBoards KnowledgeBoard[] + knowledgeItems KnowledgeItem[] } enum AdminRole { @@ -751,3 +753,49 @@ model AskAIQuery { @@index([tenantId, 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]) +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 22f6fa3..d06e5eb 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/modules/knowledge/knowledge.controller.ts b/apps/api/src/modules/knowledge/knowledge.controller.ts new file mode 100644 index 0000000..ac41145 --- /dev/null +++ b/apps/api/src/modules/knowledge/knowledge.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/knowledge/knowledge.module.ts b/apps/api/src/modules/knowledge/knowledge.module.ts new file mode 100644 index 0000000..fdc753a --- /dev/null +++ b/apps/api/src/modules/knowledge/knowledge.module.ts @@ -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 {} diff --git a/apps/api/src/modules/knowledge/knowledge.service.ts b/apps/api/src/modules/knowledge/knowledge.service.ts new file mode 100644 index 0000000..123c875 --- /dev/null +++ b/apps/api/src/modules/knowledge/knowledge.service.ts @@ -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, + })); + } +} diff --git a/apps/api/src/modules/my/my.controller.ts b/apps/api/src/modules/my/my.controller.ts index b675580..ade36a9 100644 --- a/apps/api/src/modules/my/my.controller.ts +++ b/apps/api/src/modules/my/my.controller.ts @@ -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); diff --git a/apps/api/src/modules/my/my.module.ts b/apps/api/src/modules/my/my.module.ts index 5a61bd6..c0326dc 100644 --- a/apps/api/src/modules/my/my.module.ts +++ b/apps/api/src/modules/my/my.module.ts @@ -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], }) diff --git a/apps/web/app/api/my/knowledge/route.ts b/apps/web/app/api/my/knowledge/route.ts new file mode 100644 index 0000000..9e65d8a --- /dev/null +++ b/apps/web/app/api/my/knowledge/route.ts @@ -0,0 +1,11 @@ +import { jsonResponse, memberApiFetch } from '../../../_lib/api'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request): Promise { + 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); +} diff --git a/apps/web/app/my/knowledge/KnowledgeItem.tsx b/apps/web/app/my/knowledge/KnowledgeItem.tsx new file mode 100644 index 0000000..a5e0479 --- /dev/null +++ b/apps/web/app/my/knowledge/KnowledgeItem.tsx @@ -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 ( +
+ + {open && ( +
+

{answer}

+ {tags.length > 0 && ( +
+ {tags.map((t) => ( + {t} + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/app/my/knowledge/KnowledgeSearch.tsx b/apps/web/app/my/knowledge/KnowledgeSearch.tsx new file mode 100644 index 0000000..ee8d9c6 --- /dev/null +++ b/apps/web/app/my/knowledge/KnowledgeSearch.tsx @@ -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 ( + 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" + /> + ); +} diff --git a/apps/web/app/my/knowledge/page.tsx b/apps/web/app/my/knowledge/page.tsx new file mode 100644 index 0000000..7e79b94 --- /dev/null +++ b/apps/web/app/my/knowledge/page.tsx @@ -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 { + 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 ( +
+
+

Knowledge base

+ ← Dashboard +
+ + + + {boards.length === 0 ? ( +
+

{q ? 'No matching articles.' : 'No knowledge articles yet.'}

+ {!q &&

Your chapter admin will publish FAQs and guides here.

} +
+ ) : ( +
+ {boards.map((b) => ( +
+

{b.name}

+ {b.description &&

{b.description}

} +
+ {b.items.map((item) => ( + + ))} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web/app/my/layout.tsx b/apps/web/app/my/layout.tsx index aaa618d..b13af5a 100644 --- a/apps/web/app/my/layout.tsx +++ b/apps/web/app/my/layout.tsx @@ -5,6 +5,7 @@ import { redirect } from 'next/navigation'; const NAV = [ { href: '/my', label: 'Dashboard' }, { href: '/my/ask', label: 'Ask AI' }, + { href: '/my/knowledge', label: 'Knowledge' }, { href: '/my/digest', label: 'Digest' }, { href: '/my/events', label: 'Events' }, { href: '/my/seva', label: 'Seva & Points' },