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,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;
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
|||||||
Reference in New Issue
Block a user