Files
tower/docs/superpowers/plans/2026-05-28-admin-dashboard.md
2026-06-09 02:02:40 +05:30

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.tsPrismaService 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:

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 &amp; 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 &amp; 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"