Files
MOPC-Portal/docs/superpowers/plans/2026-04-28-pr7-email-team-from-project.md
Matt b867c45114 feat: Email Team button + custom-email dialog on project page
Adds a PROJECT_TEAM recipient type to the message router (resolver
returns team members + project lead) and an "Email Team" button on
the admin project detail page that opens a self-contained dialog
matching the look of /admin/messages: subject, body (pre-filled
with "Hello [Project Title] team,\n\n"), live HTML preview iframe,
"Send test to me" + "Send to N" actions.

The composer reuses the existing message.previewEmail and
message.send tRPC procedures end-to-end — no parallel email
infrastructure introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:29:42 +02:00

6.5 KiB

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 <ProjectEmailDialog> 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
// 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:

recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),

with:

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'):

    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<string>()
      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 <ProjectEmailDialog>

  • 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:
<Button variant="outline" onClick={() => setEmailDialogOpen(true)}>
  <Mail className="mr-2 h-4 w-4" />
  Email Team
</Button>

(Add Mail to the lucide imports. Add useState for emailDialogOpen.)

Render the dialog at the bottom of the page:

{project && (
  <ProjectEmailDialog
    open={emailDialogOpen}
    onClose={() => 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).