# PR 7 — "Email Team" Modal on Project Detail Page > **For agentic workers:** Use superpowers:executing-plans for inline execution. **Goal:** Add an "Email Team" button to `/admin/projects/[id]` that opens a modal composing a custom email to that project's team. Same look + features as `/admin/messages` (subject, body, live HTML preview, send-to-all). Body pre-filled with `Hello [Project Title] team,\n\n` so admin can edit and add their custom content beneath. **Architecture:** Adds `PROJECT_TEAM` to the existing `RecipientType` enum (5 places) + a resolver branch. New self-contained `` component reuses the existing `message.previewEmail` and `message.send` procedures. No template-system rewrite. **Spec:** New ask added during PR review (2026-04-28). Not in original spec but adjacent to §B (admin views). ## File map | File | Action | Why | |------|--------|-----| | `src/server/routers/message.ts` | Modify | Add `PROJECT_TEAM` to 4 input enums + 1 resolver case | | `src/components/admin/project-email-dialog.tsx` | Create | Self-contained modal with subject + body + live preview + send | | `src/app/(admin)/admin/projects/[id]/page.tsx` | Modify | Add "Email Team" button next to Edit button; render the dialog | | `tests/unit/message-recipient-project-team.test.ts` | Create | Resolver returns team members + submittedByUserId | ## Tasks ### Task 1: Backend — `PROJECT_TEAM` recipient type - [ ] **Step 1: Write failing test** ```ts // tests/unit/message-recipient-project-team.test.ts import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { prisma, createCaller } from '../setup' import { createTestUser, createTestProgram, createTestProject, cleanupTestData, uid, } from '../helpers' import { messageRouter } from '../../src/server/routers/message' describe('message.previewRecipients — PROJECT_TEAM', () => { let programId: string let admin: { id: string; email: string; role: 'SUPER_ADMIN' } let projectId: string const userIds: string[] = [] beforeAll(async () => { const program = await createTestProgram({ name: `proj-team-${uid()}` }) programId = program.id const lead = await createTestUser('APPLICANT') userIds.push(lead.id) const project = await createTestProject(programId, { title: 'TestProj' }) projectId = project.id await prisma.project.update({ where: { id: projectId }, data: { submittedByUserId: lead.id } }) const member1 = await createTestUser('APPLICANT') const member2 = await createTestUser('APPLICANT') userIds.push(member1.id, member2.id) await prisma.teamMember.createMany({ data: [ { id: uid('tm'), projectId, userId: member1.id, role: 'MEMBER' }, { id: uid('tm'), projectId, userId: member2.id, role: 'MEMBER' }, ], }) const a = await createTestUser('SUPER_ADMIN') userIds.push(a.id) admin = { id: a.id, email: a.email, role: 'SUPER_ADMIN' } }) afterAll(async () => { await cleanupTestData(programId, userIds) }) it('counts the lead + 2 team members', async () => { const caller = createCaller(messageRouter, admin) const result = await caller.previewRecipients({ recipientType: 'PROJECT_TEAM', recipientFilter: { projectId }, }) expect(result.totalApplicants).toBe(3) }) it('returns 0 when projectId is missing', async () => { const caller = createCaller(messageRouter, admin) const result = await caller.previewRecipients({ recipientType: 'PROJECT_TEAM', recipientFilter: {}, }) expect(result.totalApplicants).toBe(0) }) }) ``` - [ ] **Step 2: Run, expect FAIL** — `'PROJECT_TEAM'` not in enum. - [ ] **Step 3: Patch `src/server/routers/message.ts`** Replace ALL FIVE enum literal lines: ```ts recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']), ``` with: ```ts recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'PROJECT_TEAM', 'ALL']), ``` (Use Edit's `replace_all: true` since the line is identical at all 5 occurrences.) Add a new case in `resolveRecipients` (after `'PROGRAM_TEAM'`): ```ts case 'PROJECT_TEAM': { const projectId = filter?.projectId as string if (!projectId) return [] const [teamMembers, project] = await Promise.all([ prisma.teamMember.findMany({ where: { projectId }, select: { userId: true }, }), prisma.project.findUnique({ where: { id: projectId }, select: { submittedByUserId: true }, }), ]) const ids = new Set() for (const tm of teamMembers) ids.add(tm.userId) if (project?.submittedByUserId) ids.add(project.submittedByUserId) return [...ids] } ``` - [ ] **Step 4: Re-run, expect PASS.** ### Task 2: Build `` - [ ] **Step 1: Create the component** (full code in execution) Behaviour: - Open via `open: boolean; onClose: () => void; projectId: string; projectTitle: string` props. - On open, body field is pre-filled: ``Hello ${projectTitle} team,\n\n``. Cursor placed at the end. - Subject field default: empty (admin types). - Live HTML preview pane below the form, calling `message.previewEmail` with `{ subject, body }` (debounced 300ms). - Recipient count line: calls `message.previewRecipients` with `PROJECT_TEAM` + `{ projectId }`. Shows "Will email N team members." - "Send Test" button: sends to the admin only via `message.sendTest`. - "Send" button: calls `message.send` with `recipientType: 'PROJECT_TEAM'`, `recipientFilter: { projectId }`, `deliveryChannels: ['EMAIL']`, `linkType: 'NONE'`. - On success: toast + close dialog. On error: toast. ### Task 3: Wire the button on project detail page - [ ] **Step 1: Add button next to Edit** in `src/app/(admin)/admin/projects/[id]/page.tsx`: ```tsx ``` (Add `Mail` to the lucide imports. Add `useState` for `emailDialogOpen`.) Render the dialog at the bottom of the page: ```tsx {project && ( setEmailDialogOpen(false)} projectId={project.id} projectTitle={project.title} /> )} ``` ### Task 4: Verify + commit - [ ] `npx vitest run tests/unit` → all pass. - [ ] `npm run typecheck` → clean. - [ ] `npm run build` → clean. - [ ] Commit with message referencing PR 7. ## Out of scope Template picker (admins can use plain body for now; integration with template system can land later). HTML editor (plain textarea — same UX as messages page composer).