1199 lines
36 KiB
Markdown
1199 lines
36 KiB
Markdown
# Admin Dashboard Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a Next.js admin dashboard with a full-text search page and a groups/routes management page that lets admins configure which WhatsApp groups forward messages to which.
|
|
|
|
**Architecture:** Two new NestJS modules (`GroupsModule`, `RoutesModule`) expose REST endpoints the dashboard consumes. The Next.js web app (App Router, server components) calls NestJS directly server-to-server for read operations; client-side mutations (add/delete sync routes) go through Next.js Route Handler proxies to avoid CORS. A sidebar nav wraps both pages in the shared layout.
|
|
|
|
**Tech Stack:** Next.js 16, React 19, Tailwind CSS 4, NestJS 11, Prisma 6 (`@prisma/client` already in `apps/api`), `@testing-library/react`, `jest-environment-jsdom`, `@nestjs/testing`, TypeScript 5
|
|
|
|
---
|
|
|
|
## Codebase orientation
|
|
|
|
- `apps/api/src/prisma/prisma.service.ts` — `PrismaService extends PrismaClient`; `PrismaModule` is `@Global()` so every module gets it injected without re-importing.
|
|
- `apps/api/src/app.module.ts` — imports `ConfigModule`, `PrismaModule`, `HealthModule`, `SearchModule`. Add new modules here.
|
|
- NestJS test pattern: `Test.createTestingModule({ providers: [Service, { provide: PrismaService, useValue: mockPrisma }] })`.
|
|
- `apps/web` uses `next/jest` in `jest.config.js`, `jest-environment-jsdom`, `@testing-library/react`. `fetch` is available as a global in tests.
|
|
- `process.env.API_URL ?? 'http://localhost:3001'` is the server-side base URL for all server→API calls. Not a public env var.
|
|
- Prisma schema key models: `Group { id, name, platform, platformId, isActive, accountId }`, `SyncRoute { id, sourceGroupId, targetGroupId, isActive, createdAt }`.
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
**Create:**
|
|
- `apps/api/src/modules/groups/groups.module.ts`
|
|
- `apps/api/src/modules/groups/groups.service.ts`
|
|
- `apps/api/src/modules/groups/groups.service.spec.ts`
|
|
- `apps/api/src/modules/groups/groups.controller.ts`
|
|
- `apps/api/src/modules/groups/groups.controller.spec.ts`
|
|
- `apps/api/src/modules/routes/routes.module.ts`
|
|
- `apps/api/src/modules/routes/routes.service.ts`
|
|
- `apps/api/src/modules/routes/routes.service.spec.ts`
|
|
- `apps/api/src/modules/routes/routes.controller.ts`
|
|
- `apps/api/src/modules/routes/routes.controller.spec.ts`
|
|
- `apps/web/app/search/page.tsx` — server component + exported `SearchResults` for testing
|
|
- `apps/web/app/search/page.test.tsx`
|
|
- `apps/web/app/groups/page.tsx` — server component, fetches groups + routes
|
|
- `apps/web/app/groups/RouteManager.tsx` — `'use client'` component for add/delete
|
|
- `apps/web/app/groups/RouteManager.test.tsx`
|
|
- `apps/web/app/api/routes/route.ts` — GET + POST proxy
|
|
- `apps/web/app/api/routes/[id]/route.ts` — DELETE proxy
|
|
|
|
**Modify:**
|
|
- `apps/api/src/app.module.ts` — add `GroupsModule`, `RoutesModule`
|
|
- `apps/web/app/layout.tsx` — add sidebar nav
|
|
- `apps/web/app/page.tsx` — dashboard home with navigation cards
|
|
- `apps/web/app/page.test.tsx` — update tests for new home page content
|
|
|
|
---
|
|
|
|
### Task 1: GroupsModule API — GET /groups
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/modules/groups/groups.service.ts`
|
|
- Create: `apps/api/src/modules/groups/groups.service.spec.ts`
|
|
- Create: `apps/api/src/modules/groups/groups.controller.ts`
|
|
- Create: `apps/api/src/modules/groups/groups.controller.spec.ts`
|
|
- Create: `apps/api/src/modules/groups/groups.module.ts`
|
|
|
|
- [ ] **Step 1: Write the failing service test**
|
|
|
|
Create `apps/api/src/modules/groups/groups.service.spec.ts`:
|
|
|
|
```typescript
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { GroupsService } from './groups.service';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
|
|
const mockGroups = [
|
|
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1' },
|
|
{ id: 'grp_2', name: 'Beta', platform: 'whatsapp', platformId: '222@g.us', isActive: true, accountId: null },
|
|
];
|
|
|
|
describe('GroupsService', () => {
|
|
let service: GroupsService;
|
|
const mockPrisma = { group: { findMany: jest.fn().mockResolvedValue(mockGroups) } };
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
GroupsService,
|
|
{ provide: PrismaService, useValue: mockPrisma },
|
|
],
|
|
}).compile();
|
|
service = module.get<GroupsService>(GroupsService);
|
|
});
|
|
|
|
it('returns all groups ordered by name', async () => {
|
|
const result = await service.list();
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].name).toBe('Alpha');
|
|
expect(mockPrisma.group.findMany).toHaveBeenCalledWith({
|
|
orderBy: { name: 'asc' },
|
|
select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true },
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run test to verify it fails**
|
|
|
|
```bash
|
|
pnpm --filter @tower/api test -- --testPathPattern=groups.service
|
|
```
|
|
Expected: FAIL with "Cannot find module './groups.service'"
|
|
|
|
- [ ] **Step 3: Implement GroupsService**
|
|
|
|
Create `apps/api/src/modules/groups/groups.service.ts`:
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
|
|
@Injectable()
|
|
export class GroupsService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
list() {
|
|
return this.prisma.group.findMany({
|
|
orderBy: { name: 'asc' },
|
|
select: { id: true, name: true, platform: true, platformId: true, isActive: true, accountId: true },
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run service test to verify it passes**
|
|
|
|
```bash
|
|
pnpm --filter @tower/api test -- --testPathPattern=groups.service
|
|
```
|
|
Expected: PASS (1 test)
|
|
|
|
- [ ] **Step 5: Write the failing controller test**
|
|
|
|
Create `apps/api/src/modules/groups/groups.controller.spec.ts`:
|
|
|
|
```typescript
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { GroupsController } from './groups.controller';
|
|
import { GroupsService } from './groups.service';
|
|
|
|
const mockGroups = [
|
|
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp', platformId: '111@g.us', isActive: true, accountId: 'acc_1' },
|
|
];
|
|
const mockService = { list: jest.fn().mockResolvedValue(mockGroups) };
|
|
|
|
describe('GroupsController', () => {
|
|
let controller: GroupsController;
|
|
|
|
beforeEach(async () => {
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
controllers: [GroupsController],
|
|
providers: [{ provide: GroupsService, useValue: mockService }],
|
|
}).compile();
|
|
controller = module.get<GroupsController>(GroupsController);
|
|
});
|
|
|
|
it('returns groups from service', async () => {
|
|
const result = await controller.list();
|
|
expect(result).toEqual(mockGroups);
|
|
expect(mockService.list).toHaveBeenCalled();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 6: Implement GroupsController and GroupsModule**
|
|
|
|
Create `apps/api/src/modules/groups/groups.controller.ts`:
|
|
|
|
```typescript
|
|
import { Controller, Get } from '@nestjs/common';
|
|
import { GroupsService } from './groups.service';
|
|
|
|
@Controller('groups')
|
|
export class GroupsController {
|
|
constructor(private readonly groupsService: GroupsService) {}
|
|
|
|
@Get()
|
|
list() {
|
|
return this.groupsService.list();
|
|
}
|
|
}
|
|
```
|
|
|
|
Create `apps/api/src/modules/groups/groups.module.ts`:
|
|
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { GroupsController } from './groups.controller';
|
|
import { GroupsService } from './groups.service';
|
|
|
|
@Module({
|
|
controllers: [GroupsController],
|
|
providers: [GroupsService],
|
|
})
|
|
export class GroupsModule {}
|
|
```
|
|
|
|
- [ ] **Step 7: Run controller test to verify it passes**
|
|
|
|
```bash
|
|
pnpm --filter @tower/api test -- --testPathPattern=groups.controller
|
|
```
|
|
Expected: PASS (1 test)
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/groups/
|
|
git commit -m "feat(api): add GroupsModule with GET /groups endpoint"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: RoutesModule API — GET /routes, POST /routes, DELETE /routes/:id — wire both into AppModule
|
|
|
|
**Files:**
|
|
- Create: `apps/api/src/modules/routes/routes.service.ts`
|
|
- Create: `apps/api/src/modules/routes/routes.service.spec.ts`
|
|
- Create: `apps/api/src/modules/routes/routes.controller.ts`
|
|
- Create: `apps/api/src/modules/routes/routes.controller.spec.ts`
|
|
- Create: `apps/api/src/modules/routes/routes.module.ts`
|
|
- Modify: `apps/api/src/app.module.ts`
|
|
|
|
A populated `SyncRoute` record (with includes) looks like:
|
|
```
|
|
{ id, sourceGroupId, targetGroupId, isActive, createdAt, sourceGroup: { name }, targetGroup: { name } }
|
|
```
|
|
|
|
- [ ] **Step 1: Write the failing service tests**
|
|
|
|
Create `apps/api/src/modules/routes/routes.service.spec.ts`:
|
|
|
|
```typescript
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import { RoutesService } from './routes.service';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
|
|
const mockRoute = {
|
|
id: 'rt_1',
|
|
sourceGroupId: 'grp_1',
|
|
targetGroupId: 'grp_2',
|
|
isActive: true,
|
|
createdAt: new Date(),
|
|
sourceGroup: { name: 'Alpha' },
|
|
targetGroup: { name: 'Beta' },
|
|
};
|
|
|
|
describe('RoutesService', () => {
|
|
let service: RoutesService;
|
|
const mockPrisma = {
|
|
syncRoute: {
|
|
findMany: jest.fn().mockResolvedValue([mockRoute]),
|
|
create: jest.fn().mockResolvedValue(mockRoute),
|
|
delete: jest.fn().mockResolvedValue(mockRoute),
|
|
},
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
jest.clearAllMocks();
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
providers: [
|
|
RoutesService,
|
|
{ provide: PrismaService, useValue: mockPrisma },
|
|
],
|
|
}).compile();
|
|
service = module.get<RoutesService>(RoutesService);
|
|
});
|
|
|
|
describe('list', () => {
|
|
it('returns all routes with group names', async () => {
|
|
const result = await service.list();
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].sourceGroup.name).toBe('Alpha');
|
|
expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith({
|
|
where: undefined,
|
|
include: {
|
|
sourceGroup: { select: { name: true } },
|
|
targetGroup: { select: { name: true } },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
});
|
|
|
|
it('filters by sourceGroupId when provided', async () => {
|
|
await service.list('grp_1');
|
|
expect(mockPrisma.syncRoute.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: { sourceGroupId: 'grp_1' } }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('creates a route and returns it with group names', async () => {
|
|
const result = await service.create('grp_1', 'grp_2');
|
|
expect(result).toEqual(mockRoute);
|
|
expect(mockPrisma.syncRoute.create).toHaveBeenCalledWith({
|
|
data: { sourceGroupId: 'grp_1', targetGroupId: 'grp_2' },
|
|
include: {
|
|
sourceGroup: { select: { name: true } },
|
|
targetGroup: { select: { name: true } },
|
|
},
|
|
});
|
|
});
|
|
|
|
it('throws BadRequestException when sourceGroupId is empty', async () => {
|
|
await expect(service.create('', 'grp_2')).rejects.toThrow(BadRequestException);
|
|
});
|
|
|
|
it('throws BadRequestException when targetGroupId is empty', async () => {
|
|
await expect(service.create('grp_1', '')).rejects.toThrow(BadRequestException);
|
|
});
|
|
});
|
|
|
|
describe('remove', () => {
|
|
it('deletes a route by id', async () => {
|
|
await service.remove('rt_1');
|
|
expect(mockPrisma.syncRoute.delete).toHaveBeenCalledWith({ where: { id: 'rt_1' } });
|
|
});
|
|
|
|
it('throws NotFoundException when route does not exist (Prisma P2025)', async () => {
|
|
mockPrisma.syncRoute.delete.mockRejectedValueOnce({ code: 'P2025' });
|
|
await expect(service.remove('bad_id')).rejects.toThrow(NotFoundException);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
```bash
|
|
pnpm --filter @tower/api test -- --testPathPattern=routes.service
|
|
```
|
|
Expected: FAIL with "Cannot find module './routes.service'"
|
|
|
|
- [ ] **Step 3: Implement RoutesService**
|
|
|
|
Create `apps/api/src/modules/routes/routes.service.ts`:
|
|
|
|
```typescript
|
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
|
|
const routeInclude = {
|
|
sourceGroup: { select: { name: true } },
|
|
targetGroup: { select: { name: true } },
|
|
} as const;
|
|
|
|
@Injectable()
|
|
export class RoutesService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
list(sourceGroupId?: string) {
|
|
return this.prisma.syncRoute.findMany({
|
|
where: sourceGroupId ? { sourceGroupId } : undefined,
|
|
include: routeInclude,
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
}
|
|
|
|
create(sourceGroupId: string, targetGroupId: string) {
|
|
if (!sourceGroupId || !targetGroupId) {
|
|
throw new BadRequestException('sourceGroupId and targetGroupId are required');
|
|
}
|
|
return this.prisma.syncRoute.create({
|
|
data: { sourceGroupId, targetGroupId },
|
|
include: routeInclude,
|
|
});
|
|
}
|
|
|
|
async remove(id: string) {
|
|
try {
|
|
await this.prisma.syncRoute.delete({ where: { id } });
|
|
} catch (e) {
|
|
if ((e as { code?: string })?.code === 'P2025') {
|
|
throw new NotFoundException(`Route ${id} not found`);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run service tests to verify they pass**
|
|
|
|
```bash
|
|
pnpm --filter @tower/api test -- --testPathPattern=routes.service
|
|
```
|
|
Expected: PASS (7 tests)
|
|
|
|
- [ ] **Step 5: Write the failing controller tests**
|
|
|
|
Create `apps/api/src/modules/routes/routes.controller.spec.ts`:
|
|
|
|
```typescript
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { RoutesController } from './routes.controller';
|
|
import { RoutesService } from './routes.service';
|
|
|
|
const mockRoute = {
|
|
id: 'rt_1',
|
|
sourceGroupId: 'grp_1',
|
|
targetGroupId: 'grp_2',
|
|
sourceGroup: { name: 'Alpha' },
|
|
targetGroup: { name: 'Beta' },
|
|
};
|
|
const mockService = {
|
|
list: jest.fn().mockResolvedValue([mockRoute]),
|
|
create: jest.fn().mockResolvedValue(mockRoute),
|
|
remove: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
|
|
describe('RoutesController', () => {
|
|
let controller: RoutesController;
|
|
|
|
beforeEach(async () => {
|
|
jest.clearAllMocks();
|
|
const module: TestingModule = await Test.createTestingModule({
|
|
controllers: [RoutesController],
|
|
providers: [{ provide: RoutesService, useValue: mockService }],
|
|
}).compile();
|
|
controller = module.get<RoutesController>(RoutesController);
|
|
});
|
|
|
|
it('list() delegates to service with no filter', async () => {
|
|
const result = await controller.list(undefined);
|
|
expect(result).toEqual([mockRoute]);
|
|
expect(mockService.list).toHaveBeenCalledWith(undefined);
|
|
});
|
|
|
|
it('list() passes sourceGroupId filter to service', async () => {
|
|
await controller.list('grp_1');
|
|
expect(mockService.list).toHaveBeenCalledWith('grp_1');
|
|
});
|
|
|
|
it('create() extracts body fields and delegates to service', async () => {
|
|
const result = await controller.create({ sourceGroupId: 'grp_1', targetGroupId: 'grp_2' });
|
|
expect(result).toEqual(mockRoute);
|
|
expect(mockService.create).toHaveBeenCalledWith('grp_1', 'grp_2');
|
|
});
|
|
|
|
it('remove() delegates id to service', async () => {
|
|
await controller.remove('rt_1');
|
|
expect(mockService.remove).toHaveBeenCalledWith('rt_1');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 6: Implement RoutesController, RoutesModule, and wire into AppModule**
|
|
|
|
Create `apps/api/src/modules/routes/routes.controller.ts`:
|
|
|
|
```typescript
|
|
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common';
|
|
import { RoutesService } from './routes.service';
|
|
|
|
@Controller('routes')
|
|
export class RoutesController {
|
|
constructor(private readonly routesService: RoutesService) {}
|
|
|
|
@Get()
|
|
list(@Query('sourceGroupId') sourceGroupId?: string) {
|
|
return this.routesService.list(sourceGroupId);
|
|
}
|
|
|
|
@Post()
|
|
create(@Body() body: { sourceGroupId?: string; targetGroupId?: string }) {
|
|
return this.routesService.create(body.sourceGroupId ?? '', body.targetGroupId ?? '');
|
|
}
|
|
|
|
@Delete(':id')
|
|
@HttpCode(204)
|
|
remove(@Param('id') id: string) {
|
|
return this.routesService.remove(id);
|
|
}
|
|
}
|
|
```
|
|
|
|
Create `apps/api/src/modules/routes/routes.module.ts`:
|
|
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { RoutesController } from './routes.controller';
|
|
import { RoutesService } from './routes.service';
|
|
|
|
@Module({
|
|
controllers: [RoutesController],
|
|
providers: [RoutesService],
|
|
})
|
|
export class RoutesModule {}
|
|
```
|
|
|
|
Replace `apps/api/src/app.module.ts` in full:
|
|
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { ConfigModule } from '@nestjs/config';
|
|
import { PrismaModule } from './prisma/prisma.module';
|
|
import { HealthModule } from './modules/health/health.module';
|
|
import { SearchModule } from './modules/search/search.module';
|
|
import { GroupsModule } from './modules/groups/groups.module';
|
|
import { RoutesModule } from './modules/routes/routes.module';
|
|
|
|
@Module({
|
|
imports: [
|
|
ConfigModule.forRoot({ isGlobal: true }),
|
|
PrismaModule,
|
|
HealthModule,
|
|
SearchModule,
|
|
GroupsModule,
|
|
RoutesModule,
|
|
],
|
|
})
|
|
export class AppModule {}
|
|
```
|
|
|
|
- [ ] **Step 7: Run controller tests and full API suite**
|
|
|
|
```bash
|
|
pnpm --filter @tower/api test -- --testPathPattern=routes.controller
|
|
```
|
|
Expected: PASS (4 tests)
|
|
|
|
```bash
|
|
pnpm --filter @tower/api test
|
|
```
|
|
Expected: All API test suites pass
|
|
|
|
- [ ] **Step 8: Commit**
|
|
|
|
```bash
|
|
git add apps/api/src/modules/routes/ apps/api/src/app.module.ts
|
|
git commit -m "feat(api): add RoutesModule with GET/POST/DELETE /routes endpoints"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Web dashboard layout and home page
|
|
|
|
**Files:**
|
|
- Modify: `apps/web/app/layout.tsx`
|
|
- Modify: `apps/web/app/page.tsx`
|
|
- Modify: `apps/web/app/page.test.tsx`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Replace `apps/web/app/page.test.tsx` in full:
|
|
|
|
```tsx
|
|
import { render, screen } from '@testing-library/react';
|
|
import Home from './page';
|
|
|
|
describe('Home page', () => {
|
|
it('renders the TOWER heading', () => {
|
|
render(<Home />);
|
|
expect(screen.getByRole('heading', { name: /insignia tower/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders a link to the search page', () => {
|
|
render(<Home />);
|
|
expect(screen.getByRole('link', { name: /search/i })).toHaveAttribute('href', '/search');
|
|
});
|
|
|
|
it('renders a link to the groups page', () => {
|
|
render(<Home />);
|
|
expect(screen.getByRole('link', { name: /groups/i })).toHaveAttribute('href', '/groups');
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify the two new ones fail**
|
|
|
|
```bash
|
|
pnpm --filter @tower/web test -- --testPathPattern=app/page
|
|
```
|
|
Expected: 2 failures — "renders a link to the search page" and "renders a link to the groups page"
|
|
|
|
- [ ] **Step 3: Update layout.tsx and page.tsx**
|
|
|
|
Replace `apps/web/app/layout.tsx` in full:
|
|
|
|
```tsx
|
|
import type { Metadata } from 'next';
|
|
import Link from 'next/link';
|
|
import './globals.css';
|
|
|
|
export const metadata: Metadata = {
|
|
title: 'Insignia TOWER',
|
|
description: 'Community Knowledge Infrastructure Platform',
|
|
};
|
|
|
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<html lang="en">
|
|
<body className="flex min-h-screen bg-gray-50 text-gray-900 antialiased">
|
|
<nav className="w-52 shrink-0 bg-white border-r border-gray-200 p-4 flex flex-col gap-1">
|
|
<span className="font-bold text-base mb-4">TOWER</span>
|
|
<Link href="/search" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
|
Search
|
|
</Link>
|
|
<Link href="/groups" className="rounded px-3 py-2 text-sm hover:bg-gray-100">
|
|
Groups & Routes
|
|
</Link>
|
|
</nav>
|
|
<main className="flex-1 overflow-auto p-6">{children}</main>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
Replace `apps/web/app/page.tsx` in full:
|
|
|
|
```tsx
|
|
import Link from 'next/link';
|
|
|
|
export default function Home() {
|
|
return (
|
|
<div className="flex flex-col gap-6 max-w-xl">
|
|
<h1 className="text-3xl font-bold tracking-tight">Insignia TOWER</h1>
|
|
<p className="text-gray-500">Community Knowledge Infrastructure Platform</p>
|
|
<div className="flex gap-4">
|
|
<Link
|
|
href="/search"
|
|
className="flex-1 rounded-xl border border-gray-200 bg-white p-5 hover:border-blue-400 transition-colors"
|
|
>
|
|
<h2 className="font-semibold mb-1">Search</h2>
|
|
<p className="text-sm text-gray-500">Full-text search of approved messages</p>
|
|
</Link>
|
|
<Link
|
|
href="/groups"
|
|
className="flex-1 rounded-xl border border-gray-200 bg-white p-5 hover:border-blue-400 transition-colors"
|
|
>
|
|
<h2 className="font-semibold mb-1">Groups</h2>
|
|
<p className="text-sm text-gray-500">Manage groups and sync routes</p>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify all 3 pass**
|
|
|
|
```bash
|
|
pnpm --filter @tower/web test -- --testPathPattern=app/page
|
|
```
|
|
Expected: PASS (3 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/web/app/layout.tsx apps/web/app/page.tsx apps/web/app/page.test.tsx
|
|
git commit -m "feat(web): add sidebar nav layout and dashboard home page"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Web search page
|
|
|
|
**Files:**
|
|
- Create: `apps/web/app/search/page.tsx`
|
|
- Create: `apps/web/app/search/page.test.tsx`
|
|
|
|
The page is an async server component. It reads `searchParams.q` and `searchParams.page`, fetches from `${API_URL}/search` server-to-server, and delegates rendering to an exported `SearchResults` component (which is pure JSX and testable with RTL).
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `apps/web/app/search/page.test.tsx`:
|
|
|
|
```tsx
|
|
import { render, screen } from '@testing-library/react';
|
|
import { SearchResults } from './page';
|
|
|
|
const makeHit = (id: string, content: string) => ({
|
|
id,
|
|
content,
|
|
senderName: 'Alice',
|
|
sourceGroupName: 'UP Parivar Dallas',
|
|
tags: ['#important'],
|
|
approvedAt: 1748390400000,
|
|
});
|
|
|
|
describe('SearchResults', () => {
|
|
it('shows "no results" when hits array is empty', () => {
|
|
render(<SearchResults hits={[]} total={0} q="missing" page={1} />);
|
|
expect(screen.getByText(/no results/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders each hit content', () => {
|
|
render(
|
|
<SearchResults
|
|
hits={[makeHit('m1', 'Hello world'), makeHit('m2', 'Event tonight')]}
|
|
total={2}
|
|
q="hello"
|
|
page={1}
|
|
/>,
|
|
);
|
|
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
|
expect(screen.getByText('Event tonight')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows the total result count', () => {
|
|
render(<SearchResults hits={[makeHit('m1', 'Test')]} total={42} q="test" page={1} />);
|
|
expect(screen.getByText(/42/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows sender name and group name for each hit', () => {
|
|
render(<SearchResults hits={[makeHit('m1', 'Test')]} total={1} q="test" page={1} />);
|
|
expect(screen.getByText(/alice/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/UP Parivar Dallas/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows tags for each hit', () => {
|
|
render(<SearchResults hits={[makeHit('m1', 'Test')]} total={1} q="test" page={1} />);
|
|
expect(screen.getByText('#important')).toBeInTheDocument();
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
```bash
|
|
pnpm --filter @tower/web test -- --testPathPattern=search/page
|
|
```
|
|
Expected: FAIL with "Cannot find module './page'"
|
|
|
|
- [ ] **Step 3: Implement the search page**
|
|
|
|
Create `apps/web/app/search/page.tsx`:
|
|
|
|
```tsx
|
|
interface MeiliHit {
|
|
id: string;
|
|
content: string;
|
|
senderName: string;
|
|
sourceGroupName: string;
|
|
tags: string[];
|
|
approvedAt: number;
|
|
}
|
|
|
|
interface SearchResponse {
|
|
hits: MeiliHit[];
|
|
total: number;
|
|
page: number;
|
|
limit: number;
|
|
query: string;
|
|
}
|
|
|
|
export function SearchResults({
|
|
hits,
|
|
total,
|
|
q,
|
|
page,
|
|
}: {
|
|
hits: MeiliHit[];
|
|
total: number;
|
|
q: string;
|
|
page: number;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<form method="GET" className="flex gap-2">
|
|
<input
|
|
name="q"
|
|
defaultValue={q}
|
|
placeholder="Search approved messages…"
|
|
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
|
>
|
|
Search
|
|
</button>
|
|
</form>
|
|
|
|
{hits.length === 0 ? (
|
|
<p className="text-gray-500 text-sm">No results{q ? ` for "${q}"` : ''}.</p>
|
|
) : (
|
|
<>
|
|
<p className="text-sm text-gray-500">
|
|
{total} result{total !== 1 ? 's' : ''}
|
|
</p>
|
|
<ul className="flex flex-col gap-3">
|
|
{hits.map((hit) => (
|
|
<li key={hit.id} className="rounded-xl border border-gray-200 bg-white p-4">
|
|
<p className="text-sm">{hit.content}</p>
|
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-400">
|
|
<span>{hit.senderName}</span>
|
|
<span>·</span>
|
|
<span>{hit.sourceGroupName}</span>
|
|
<span>·</span>
|
|
<span>{new Date(hit.approvedAt).toLocaleDateString()}</span>
|
|
{hit.tags.map((tag) => (
|
|
<span key={tag} className="rounded-full bg-blue-50 px-2 py-0.5 text-blue-600">
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default async function SearchPage({
|
|
searchParams,
|
|
}: {
|
|
searchParams: Promise<{ q?: string; page?: string }>;
|
|
}) {
|
|
const { q = '', page = '1' } = await searchParams;
|
|
const apiUrl = process.env.API_URL ?? 'http://localhost:3001';
|
|
const url = new URL(`${apiUrl}/search`);
|
|
url.searchParams.set('q', q);
|
|
url.searchParams.set('page', page);
|
|
|
|
let data: SearchResponse = { hits: [], total: 0, page: 1, limit: 20, query: q };
|
|
try {
|
|
const res = await fetch(url, { cache: 'no-store' });
|
|
if (res.ok) data = await res.json();
|
|
} catch {
|
|
// API unavailable — render empty results
|
|
}
|
|
|
|
return (
|
|
<div className="max-w-2xl">
|
|
<h1 className="text-xl font-semibold mb-4">Search</h1>
|
|
<SearchResults hits={data.hits} total={data.total} q={q} page={Number(page)} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
|
|
|
```bash
|
|
pnpm --filter @tower/web test -- --testPathPattern=search/page
|
|
```
|
|
Expected: PASS (5 tests)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add apps/web/app/search/
|
|
git commit -m "feat(web): add search page with full-text message search UI"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Web groups page — RouteManager, groups page, and Route Handler proxies
|
|
|
|
**Files:**
|
|
- Create: `apps/web/app/groups/RouteManager.tsx`
|
|
- Create: `apps/web/app/groups/RouteManager.test.tsx`
|
|
- Create: `apps/web/app/groups/page.tsx`
|
|
- Create: `apps/web/app/api/routes/route.ts`
|
|
- Create: `apps/web/app/api/routes/[id]/route.ts`
|
|
|
|
Types used throughout this task:
|
|
- `Group`: `{ id: string; name: string; platform: string }`
|
|
- `Route`: `{ id: string; sourceGroupId: string; targetGroupId: string; sourceGroup: { name: string }; targetGroup: { name: string } }`
|
|
|
|
- [ ] **Step 1: Write the failing RouteManager tests**
|
|
|
|
Create `apps/web/app/groups/RouteManager.test.tsx`:
|
|
|
|
```tsx
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { RouteManager } from './RouteManager';
|
|
|
|
const groups = [
|
|
{ id: 'grp_1', name: 'Alpha', platform: 'whatsapp' },
|
|
{ id: 'grp_2', name: 'Beta', platform: 'whatsapp' },
|
|
];
|
|
|
|
const routes = [
|
|
{
|
|
id: 'rt_1',
|
|
sourceGroupId: 'grp_1',
|
|
targetGroupId: 'grp_2',
|
|
sourceGroup: { name: 'Alpha' },
|
|
targetGroup: { name: 'Beta' },
|
|
},
|
|
];
|
|
|
|
let fetchSpy: jest.SpyInstance;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = jest.spyOn(global, 'fetch');
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
describe('RouteManager', () => {
|
|
it('renders existing routes with source → target names', () => {
|
|
render(<RouteManager groups={groups} initialRoutes={routes} />);
|
|
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
|
expect(screen.getByText('Beta')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders two group select dropdowns for adding a route', () => {
|
|
render(<RouteManager groups={groups} initialRoutes={routes} />);
|
|
expect(screen.getAllByRole('combobox')).toHaveLength(2);
|
|
});
|
|
|
|
it('calls POST /api/routes when Add route is submitted', async () => {
|
|
fetchSpy.mockResolvedValueOnce(
|
|
new Response(
|
|
JSON.stringify({ id: 'rt_new', sourceGroupId: 'grp_1', targetGroupId: 'grp_2', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Beta' } }),
|
|
{ status: 201, headers: { 'Content-Type': 'application/json' } },
|
|
),
|
|
);
|
|
render(<RouteManager groups={groups} initialRoutes={[]} />);
|
|
const [sourceSelect, targetSelect] = screen.getAllByRole('combobox');
|
|
fireEvent.change(sourceSelect, { target: { value: 'grp_1' } });
|
|
fireEvent.change(targetSelect, { target: { value: 'grp_2' } });
|
|
fireEvent.click(screen.getByRole('button', { name: /add route/i }));
|
|
await waitFor(() => {
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
'/api/routes',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('shows a delete button for each existing route', () => {
|
|
render(<RouteManager groups={groups} initialRoutes={routes} />);
|
|
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('calls DELETE /api/routes/:id when a route is deleted', async () => {
|
|
fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
|
render(<RouteManager groups={groups} initialRoutes={routes} />);
|
|
fireEvent.click(screen.getByRole('button', { name: /delete/i }));
|
|
await waitFor(() => {
|
|
expect(fetchSpy).toHaveBeenCalledWith(
|
|
'/api/routes/rt_1',
|
|
expect.objectContaining({ method: 'DELETE' }),
|
|
);
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
|
|
|
```bash
|
|
pnpm --filter @tower/web test -- --testPathPattern=groups/RouteManager
|
|
```
|
|
Expected: FAIL with "Cannot find module './RouteManager'"
|
|
|
|
- [ ] **Step 3: Implement RouteManager**
|
|
|
|
Create `apps/web/app/groups/RouteManager.tsx`:
|
|
|
|
```tsx
|
|
'use client';
|
|
|
|
import { useState } from 'react';
|
|
|
|
interface Group {
|
|
id: string;
|
|
name: string;
|
|
platform: string;
|
|
}
|
|
|
|
interface Route {
|
|
id: string;
|
|
sourceGroupId: string;
|
|
targetGroupId: string;
|
|
sourceGroup: { name: string };
|
|
targetGroup: { name: string };
|
|
}
|
|
|
|
export function RouteManager({
|
|
groups,
|
|
initialRoutes,
|
|
}: {
|
|
groups: Group[];
|
|
initialRoutes: Route[];
|
|
}) {
|
|
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
|
|
const [sourceId, setSourceId] = useState('');
|
|
const [targetId, setTargetId] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
async function addRoute() {
|
|
if (!sourceId || !targetId) return;
|
|
setBusy(true);
|
|
try {
|
|
const res = await fetch('/api/routes', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupId: targetId }),
|
|
});
|
|
if (!res.ok) return;
|
|
const created: Route = await res.json();
|
|
setRoutes((prev) => [created, ...prev]);
|
|
setSourceId('');
|
|
setTargetId('');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function deleteRoute(id: string) {
|
|
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
|
|
if (res.ok) setRoutes((prev) => prev.filter((r) => r.id !== id));
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<section>
|
|
<h2 className="text-base font-semibold mb-3">Active sync routes</h2>
|
|
{routes.length === 0 ? (
|
|
<p className="text-sm text-gray-400">No routes configured.</p>
|
|
) : (
|
|
<ul className="flex flex-col gap-2">
|
|
{routes.map((route) => (
|
|
<li
|
|
key={route.id}
|
|
className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-4 py-3"
|
|
>
|
|
<span className="text-sm">
|
|
<span className="font-medium">{route.sourceGroup.name}</span>
|
|
<span className="mx-2 text-gray-400">→</span>
|
|
<span className="font-medium">{route.targetGroup.name}</span>
|
|
</span>
|
|
<button
|
|
onClick={() => deleteRoute(route.id)}
|
|
className="text-xs text-red-500 hover:underline"
|
|
aria-label="Delete route"
|
|
>
|
|
Delete
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section>
|
|
<h2 className="text-base font-semibold mb-3">Add route</h2>
|
|
<div className="flex gap-2 items-center flex-wrap">
|
|
<select
|
|
value={sourceId}
|
|
onChange={(e) => setSourceId(e.target.value)}
|
|
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
|
aria-label="Source group"
|
|
>
|
|
<option value="">Source group…</option>
|
|
{groups.map((g) => (
|
|
<option key={g.id} value={g.id}>{g.name}</option>
|
|
))}
|
|
</select>
|
|
<span className="text-gray-400">→</span>
|
|
<select
|
|
value={targetId}
|
|
onChange={(e) => setTargetId(e.target.value)}
|
|
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
|
aria-label="Target group"
|
|
>
|
|
<option value="">Target group…</option>
|
|
{groups.map((g) => (
|
|
<option key={g.id} value={g.id}>{g.name}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={addRoute}
|
|
disabled={!sourceId || !targetId || busy}
|
|
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
|
>
|
|
Add route
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run RouteManager tests to verify they pass**
|
|
|
|
```bash
|
|
pnpm --filter @tower/web test -- --testPathPattern=groups/RouteManager
|
|
```
|
|
Expected: PASS (5 tests)
|
|
|
|
- [ ] **Step 5: Implement the groups page and route handler proxies**
|
|
|
|
Create `apps/web/app/groups/page.tsx`:
|
|
|
|
```tsx
|
|
import { RouteManager } from './RouteManager';
|
|
|
|
interface Group {
|
|
id: string;
|
|
name: string;
|
|
platform: string;
|
|
}
|
|
|
|
interface Route {
|
|
id: string;
|
|
sourceGroupId: string;
|
|
targetGroupId: string;
|
|
sourceGroup: { name: string };
|
|
targetGroup: { name: string };
|
|
}
|
|
|
|
async function fetchJson<T>(url: string): Promise<T | null> {
|
|
try {
|
|
const res = await fetch(url, { cache: 'no-store' });
|
|
if (!res.ok) return null;
|
|
return res.json();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export default async function GroupsPage() {
|
|
const apiUrl = process.env.API_URL ?? 'http://localhost:3001';
|
|
const [groups, routes] = await Promise.all([
|
|
fetchJson<Group[]>(`${apiUrl}/groups`),
|
|
fetchJson<Route[]>(`${apiUrl}/routes`),
|
|
]);
|
|
|
|
return (
|
|
<div className="max-w-2xl">
|
|
<h1 className="text-xl font-semibold mb-6">Groups & Routes</h1>
|
|
<RouteManager groups={groups ?? []} initialRoutes={routes ?? []} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
Create `apps/web/app/api/routes/route.ts`:
|
|
|
|
```typescript
|
|
const API_URL = process.env.API_URL ?? 'http://localhost:3001';
|
|
|
|
export async function GET(req: Request) {
|
|
const { searchParams } = new URL(req.url);
|
|
const url = new URL(`${API_URL}/routes`);
|
|
searchParams.forEach((v, k) => url.searchParams.set(k, v));
|
|
const res = await fetch(url, { cache: 'no-store' });
|
|
return Response.json(await res.json(), { status: res.status });
|
|
}
|
|
|
|
export async function POST(req: Request) {
|
|
const body = await req.json();
|
|
const res = await fetch(`${API_URL}/routes`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return Response.json(await res.json(), { status: res.status });
|
|
}
|
|
```
|
|
|
|
Create `apps/web/app/api/routes/[id]/route.ts`:
|
|
|
|
```typescript
|
|
const API_URL = process.env.API_URL ?? 'http://localhost:3001';
|
|
|
|
export async function DELETE(
|
|
_req: Request,
|
|
{ params }: { params: Promise<{ id: string }> },
|
|
) {
|
|
const { id } = await params;
|
|
const res = await fetch(`${API_URL}/routes/${id}`, { method: 'DELETE' });
|
|
return new Response(null, { status: res.status });
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run the full test suite to verify everything passes**
|
|
|
|
```bash
|
|
pnpm test
|
|
```
|
|
Expected: All test suites pass (worker: 9, API: 8, search: 1, web: 3, config: 1)
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add apps/web/app/groups/ apps/web/app/api/
|
|
git commit -m "feat(web): add groups page with RouteManager and route handler proxies"
|
|
```
|