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>
This commit is contained in:
182
docs/superpowers/plans/2026-04-28-pr7-email-team-from-project.md
Normal file
182
docs/superpowers/plans/2026-04-28-pr7-email-team-from-project.md
Normal file
@@ -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 `<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**
|
||||||
|
|
||||||
|
```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<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`:
|
||||||
|
|
||||||
|
```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:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{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).
|
||||||
@@ -75,7 +75,9 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Plus,
|
Plus,
|
||||||
X,
|
X,
|
||||||
|
Mail,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { ProjectEmailDialog } from '@/components/admin/project-email-dialog'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||||
@@ -161,6 +163,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
// State for remove member confirmation
|
// State for remove member confirmation
|
||||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||||
|
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
|
||||||
|
|
||||||
const addTeamMember = trpc.project.addTeamMember.useMutation({
|
const addTeamMember = trpc.project.addTeamMember.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -269,14 +272,29 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button variant="outline" asChild>
|
<div className="flex items-center gap-2">
|
||||||
<Link href={`/admin/projects/${projectId}/edit`}>
|
<Button variant="outline" onClick={() => setEmailDialogOpen(true)}>
|
||||||
<Edit className="mr-2 h-4 w-4" />
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
Edit
|
Email Team
|
||||||
</Link>
|
</Button>
|
||||||
</Button>
|
<Button variant="outline" asChild>
|
||||||
|
<Link href={`/admin/projects/${projectId}/edit`}>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{project && (
|
||||||
|
<ProjectEmailDialog
|
||||||
|
open={emailDialogOpen}
|
||||||
|
onClose={() => setEmailDialogOpen(false)}
|
||||||
|
projectId={project.id}
|
||||||
|
projectTitle={project.title}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
|
|||||||
177
src/components/admin/project-email-dialog.tsx
Normal file
177
src/components/admin/project-email-dialog.tsx
Normal file
@@ -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<T>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Mail className="h-5 w-5" />
|
||||||
|
Email Team — {projectTitle}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
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'}.`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email-subject">Subject</Label>
|
||||||
|
<Input
|
||||||
|
id="email-subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
placeholder="Subject of your email"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email-body">Body</Label>
|
||||||
|
<Textarea
|
||||||
|
id="email-body"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
rows={10}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
placeholder={initialBody}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The greeting is pre-filled — edit freely. The full email is wrapped in the standard
|
||||||
|
MOPC styled template when sent. Click "Show preview" to see exactly what recipients will see.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowPreview((s) => !s)}
|
||||||
|
disabled={subject.length === 0 || body.length === 0}
|
||||||
|
>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
{showPreview ? 'Hide preview' : 'Show preview'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => sendTestMutation.mutate({ subject, body })}
|
||||||
|
disabled={!canSend || sendTestMutation.isPending}
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
{sendTestMutation.isPending ? 'Sending…' : 'Send test to me'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPreview && emailPreview.data && (
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<div className="bg-muted text-xs px-3 py-2 border-b">Email preview</div>
|
||||||
|
<iframe
|
||||||
|
title="Email preview"
|
||||||
|
srcDoc={emailPreview.data.html}
|
||||||
|
className="w-full h-96 bg-white"
|
||||||
|
sandbox=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
sendMutation.mutate({
|
||||||
|
recipientType: 'PROJECT_TEAM',
|
||||||
|
recipientFilter: { projectId },
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
deliveryChannels: ['EMAIL'],
|
||||||
|
linkType: 'NONE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!canSend || sendMutation.isPending}
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
{sendMutation.isPending ? 'Sending…' : `Send to ${recipientCount}`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export const messageRouter = router({
|
|||||||
send: adminProcedure
|
send: adminProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
|
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'PROJECT_TEAM', 'ALL']),
|
||||||
recipientFilter: z.any().optional(),
|
recipientFilter: z.any().optional(),
|
||||||
roundId: z.string().optional(),
|
roundId: z.string().optional(),
|
||||||
roundIds: z.array(z.string()).optional(),
|
roundIds: z.array(z.string()).optional(),
|
||||||
@@ -482,7 +482,7 @@ export const messageRouter = router({
|
|||||||
*/
|
*/
|
||||||
previewRecipients: adminProcedure
|
previewRecipients: adminProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
|
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'PROJECT_TEAM', 'ALL']),
|
||||||
recipientFilter: z.any().optional(),
|
recipientFilter: z.any().optional(),
|
||||||
roundId: z.string().optional(),
|
roundId: z.string().optional(),
|
||||||
roundIds: z.array(z.string()).optional(),
|
roundIds: z.array(z.string()).optional(),
|
||||||
@@ -556,7 +556,7 @@ export const messageRouter = router({
|
|||||||
*/
|
*/
|
||||||
listRecipientDetails: adminProcedure
|
listRecipientDetails: adminProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
|
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'PROJECT_TEAM', 'ALL']),
|
||||||
recipientFilter: z.any().optional(),
|
recipientFilter: z.any().optional(),
|
||||||
roundId: z.string().optional(),
|
roundId: z.string().optional(),
|
||||||
roundIds: z.array(z.string()).optional(),
|
roundIds: z.array(z.string()).optional(),
|
||||||
@@ -839,6 +839,25 @@ async function resolveRecipients(
|
|||||||
return [...ids]
|
return [...ids]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
case 'ALL': {
|
case 'ALL': {
|
||||||
const users = await prisma.user.findMany({
|
const users = await prisma.user.findMany({
|
||||||
where: { status: 'ACTIVE' },
|
where: { status: 'ACTIVE' },
|
||||||
|
|||||||
60
tests/unit/message-recipient-project-team.test.ts
Normal file
60
tests/unit/message-recipient-project-team.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user