diff --git a/docs/superpowers/plans/2026-04-28-pr7-email-team-from-project.md b/docs/superpowers/plans/2026-04-28-pr7-email-team-from-project.md new file mode 100644 index 0000000..976d49e --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-pr7-email-team-from-project.md @@ -0,0 +1,182 @@ +# 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). diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index 0e0cf64..49b4e4f 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -75,7 +75,9 @@ import { Eye, Plus, X, + Mail, } from 'lucide-react' +import { ProjectEmailDialog } from '@/components/admin/project-email-dialog' import { toast } from 'sonner' import { formatDateOnly } from '@/lib/utils' import { getCountryName, getCountryFlag } from '@/lib/countries' @@ -161,6 +163,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { // State for remove member confirmation const [removingMemberId, setRemovingMemberId] = useState(null) + const [emailDialogOpen, setEmailDialogOpen] = useState(false) const addTeamMember = trpc.project.addTeamMember.useMutation({ onSuccess: () => { @@ -269,14 +272,29 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { - +
+ + +
+ {project && ( + setEmailDialogOpen(false)} + projectId={project.id} + projectTitle={project.title} + /> + )} + {/* Stats Grid */} diff --git a/src/components/admin/project-email-dialog.tsx b/src/components/admin/project-email-dialog.tsx new file mode 100644 index 0000000..1fc23f0 --- /dev/null +++ b/src/components/admin/project-email-dialog.tsx @@ -0,0 +1,177 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' +import { + Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Mail, Send, Eye } from 'lucide-react' + +interface Props { + open: boolean + onClose: () => void + projectId: string + projectTitle: string +} + +function useDebounced(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value) + useEffect(() => { + const t = setTimeout(() => setDebounced(value), delayMs) + return () => clearTimeout(t) + }, [value, delayMs]) + return debounced +} + +export function ProjectEmailDialog({ open, onClose, projectId, projectTitle }: Props) { + const initialBody = useMemo(() => `Hello ${projectTitle} team,\n\n`, [projectTitle]) + const [subject, setSubject] = useState('') + const [body, setBody] = useState(initialBody) + const [showPreview, setShowPreview] = useState(false) + + // Reset state whenever the dialog opens for a new project + useEffect(() => { + if (open) { + setSubject('') + setBody(initialBody) + setShowPreview(false) + } + }, [open, initialBody]) + + const debouncedSubject = useDebounced(subject, 300) + const debouncedBody = useDebounced(body, 300) + + const recipientPreview = trpc.message.previewRecipients.useQuery( + { recipientType: 'PROJECT_TEAM', recipientFilter: { projectId } }, + { enabled: open } + ) + + const emailPreview = trpc.message.previewEmail.useQuery( + { subject: debouncedSubject, body: debouncedBody }, + { enabled: showPreview && debouncedSubject.length > 0 && debouncedBody.length > 0 } + ) + + const sendTestMutation = trpc.message.sendTest.useMutation({ + onSuccess: ({ to }) => toast.success(`Test email sent to ${to}`), + onError: (e) => toast.error(e.message), + }) + + const sendMutation = trpc.message.send.useMutation({ + onSuccess: () => { + toast.success(`Email sent to ${recipientPreview.data?.totalApplicants ?? 0} team members`) + onClose() + }, + onError: (e) => toast.error(e.message), + }) + + const recipientCount = recipientPreview.data?.totalApplicants ?? 0 + const canSend = subject.length > 0 && body.length > 0 && recipientCount > 0 + + return ( + !v && onClose()}> + + + + + Email Team — {projectTitle} + + + Compose a custom email to all members of this project's team. + {recipientPreview.isLoading + ? ' Loading recipients…' + : ` Will be sent to ${recipientCount} team member${recipientCount === 1 ? '' : 's'}.`} + + + +
+
+ + setSubject(e.target.value)} + placeholder="Subject of your email" + maxLength={500} + /> +
+ +
+ +