36 KiB
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;PrismaModuleis@Global()so every module gets it injected without re-importing.apps/api/src/app.module.ts— importsConfigModule,PrismaModule,HealthModule,SearchModule. Add new modules here.- NestJS test pattern:
Test.createTestingModule({ providers: [Service, { provide: PrismaService, useValue: mockPrisma }] }). apps/webusesnext/jestinjest.config.js,jest-environment-jsdom,@testing-library/react.fetchis 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.tsapps/api/src/modules/groups/groups.service.tsapps/api/src/modules/groups/groups.service.spec.tsapps/api/src/modules/groups/groups.controller.tsapps/api/src/modules/groups/groups.controller.spec.tsapps/api/src/modules/routes/routes.module.tsapps/api/src/modules/routes/routes.service.tsapps/api/src/modules/routes/routes.service.spec.tsapps/api/src/modules/routes/routes.controller.tsapps/api/src/modules/routes/routes.controller.spec.tsapps/web/app/search/page.tsx— server component + exportedSearchResultsfor testingapps/web/app/search/page.test.tsxapps/web/app/groups/page.tsx— server component, fetches groups + routesapps/web/app/groups/RouteManager.tsx—'use client'component for add/deleteapps/web/app/groups/RouteManager.test.tsxapps/web/app/api/routes/route.ts— GET + POST proxyapps/web/app/api/routes/[id]/route.ts— DELETE proxy
Modify:
apps/api/src/app.module.ts— addGroupsModule,RoutesModuleapps/web/app/layout.tsx— add sidebar navapps/web/app/page.tsx— dashboard home with navigation cardsapps/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:
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
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:
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
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:
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:
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:
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
pnpm --filter @tower/api test -- --testPathPattern=groups.controller
Expected: PASS (1 test)
- Step 8: Commit
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:
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
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:
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
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:
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:
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:
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:
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
pnpm --filter @tower/api test -- --testPathPattern=routes.controller
Expected: PASS (4 tests)
pnpm --filter @tower/api test
Expected: All API test suites pass
- Step 8: Commit
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:
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
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:
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:
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
pnpm --filter @tower/web test -- --testPathPattern=app/page
Expected: PASS (3 tests)
- Step 5: Commit
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:
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
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:
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
pnpm --filter @tower/web test -- --testPathPattern=search/page
Expected: PASS (5 tests)
- Step 5: Commit
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:
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
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:
'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
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:
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:
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:
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
pnpm test
Expected: All test suites pass (worker: 9, API: 8, search: 1, web: 3, config: 1)
- Step 7: Commit
git add apps/web/app/groups/ apps/web/app/api/
git commit -m "feat(web): add groups page with RouteManager and route handler proxies"