# 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); }); 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); }); 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); }); 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); }); 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(); expect(screen.getByRole('heading', { name: /insignia tower/i })).toBeInTheDocument(); }); it('renders a link to the search page', () => { render(); expect(screen.getByRole('link', { name: /search/i })).toHaveAttribute('href', '/search'); }); it('renders a link to the groups page', () => { render(); 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 (
{children}
); } ``` Replace `apps/web/app/page.tsx` in full: ```tsx import Link from 'next/link'; export default function Home() { return (

Insignia TOWER

Community Knowledge Infrastructure Platform

Search

Full-text search of approved messages

Groups

Manage groups and sync routes

); } ``` - [ ] **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(); expect(screen.getByText(/no results/i)).toBeInTheDocument(); }); it('renders each hit content', () => { render( , ); expect(screen.getByText('Hello world')).toBeInTheDocument(); expect(screen.getByText('Event tonight')).toBeInTheDocument(); }); it('shows the total result count', () => { render(); expect(screen.getByText(/42/)).toBeInTheDocument(); }); it('shows sender name and group name for each hit', () => { render(); expect(screen.getByText(/alice/i)).toBeInTheDocument(); expect(screen.getByText(/UP Parivar Dallas/i)).toBeInTheDocument(); }); it('shows tags for each hit', () => { render(); 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 (
{hits.length === 0 ? (

No results{q ? ` for "${q}"` : ''}.

) : ( <>

{total} result{total !== 1 ? 's' : ''}

    {hits.map((hit) => (
  • {hit.content}

    {hit.senderName} · {hit.sourceGroupName} · {new Date(hit.approvedAt).toLocaleDateString()} {hit.tags.map((tag) => ( {tag} ))}
  • ))}
)}
); } 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 (

Search

); } ``` - [ ] **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(); expect(screen.getByText('Alpha')).toBeInTheDocument(); expect(screen.getByText('Beta')).toBeInTheDocument(); }); it('renders two group select dropdowns for adding a route', () => { render(); 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(); 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(); 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(); 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(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 (

Active sync routes

{routes.length === 0 ? (

No routes configured.

) : (
    {routes.map((route) => (
  • {route.sourceGroup.name} {route.targetGroup.name}
  • ))}
)}

Add route

); } ``` - [ ] **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(url: string): Promise { 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(`${apiUrl}/groups`), fetchJson(`${apiUrl}/routes`), ]); return (

Groups & Routes

); } ``` 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" ```