feat: extend notification system with batch sender, bulk dialog, and logging
Add NotificationLog schema extensions (nullable userId, email, roundId, projectId, batchId fields), batch notification sender service, and bulk notification dialog UI. Include utility scripts for debugging and seeding. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" DROP CONSTRAINT "NotificationLog_userId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "NotificationLog" ADD COLUMN "batchId" TEXT,
|
||||||
|
ADD COLUMN "email" TEXT,
|
||||||
|
ADD COLUMN "projectId" TEXT,
|
||||||
|
ADD COLUMN "roundId" TEXT,
|
||||||
|
ALTER COLUMN "userId" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "channel" SET DEFAULT 'EMAIL';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_roundId_type_idx" ON "NotificationLog"("roundId", "type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_projectId_idx" ON "NotificationLog"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_batchId_idx" ON "NotificationLog"("batchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_email_idx" ON "NotificationLog"("email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -638,6 +638,7 @@ model Project {
|
|||||||
deliberationVotes DeliberationVote[]
|
deliberationVotes DeliberationVote[]
|
||||||
deliberationResults DeliberationResult[]
|
deliberationResults DeliberationResult[]
|
||||||
submissionPromotions SubmissionPromotionEvent[]
|
submissionPromotions SubmissionPromotionEvent[]
|
||||||
|
notificationLogs NotificationLog[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -931,22 +932,34 @@ model AIUsageLog {
|
|||||||
|
|
||||||
model NotificationLog {
|
model NotificationLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String?
|
||||||
channel NotificationChannel
|
channel NotificationChannel @default(EMAIL)
|
||||||
provider String? // META, TWILIO, SMTP
|
provider String? // META, TWILIO, SMTP
|
||||||
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION
|
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION, ADVANCEMENT_NOTIFICATION, etc.
|
||||||
status String // PENDING, SENT, DELIVERED, FAILED
|
status String // PENDING, SENT, DELIVERED, FAILED
|
||||||
externalId String? // Message ID from provider
|
externalId String? // Message ID from provider
|
||||||
errorMsg String? @db.Text
|
errorMsg String? @db.Text
|
||||||
|
|
||||||
|
// Bulk notification tracking
|
||||||
|
email String? // Recipient email address
|
||||||
|
roundId String?
|
||||||
|
projectId String?
|
||||||
|
batchId String? // Groups emails from same send operation
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([roundId, type])
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([batchId])
|
||||||
|
@@index([email])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2233,6 +2246,7 @@ model Round {
|
|||||||
evaluationSummaries EvaluationSummary[]
|
evaluationSummaries EvaluationSummary[]
|
||||||
evaluationDiscussions EvaluationDiscussion[]
|
evaluationDiscussions EvaluationDiscussion[]
|
||||||
messages Message[]
|
messages Message[]
|
||||||
|
notificationLogs NotificationLog[]
|
||||||
cohorts Cohort[]
|
cohorts Cohort[]
|
||||||
liveCursor LiveProgressCursor?
|
liveCursor LiveProgressCursor?
|
||||||
|
|
||||||
|
|||||||
112
scripts/backfill-intake-round.ts
Normal file
112
scripts/backfill-intake-round.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Backfill all projects into the intake round (and any intermediate rounds
|
||||||
|
* between intake and their earliest assigned round) with COMPLETED state.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/backfill-intake-round.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling intake round states...\n')
|
||||||
|
|
||||||
|
// Find the intake round
|
||||||
|
const intakeRound = await prisma.round.findFirst({
|
||||||
|
where: { roundType: 'INTAKE' },
|
||||||
|
select: { id: true, name: true, sortOrder: true, competitionId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!intakeRound) {
|
||||||
|
console.log('❌ No INTAKE round found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Intake round: "${intakeRound.name}" (sortOrder: ${intakeRound.sortOrder})`)
|
||||||
|
|
||||||
|
// Get all rounds in the competition ordered by sortOrder
|
||||||
|
const allRounds = await prisma.round.findMany({
|
||||||
|
where: { competitionId: intakeRound.competitionId },
|
||||||
|
select: { id: true, name: true, sortOrder: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find all projects NOT in the intake round
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
projectRoundStates: {
|
||||||
|
none: { roundId: intakeRound.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
projectRoundStates: {
|
||||||
|
select: { roundId: true, round: { select: { sortOrder: true } } },
|
||||||
|
orderBy: { round: { sortOrder: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`${projects.length} projects not in intake round\n`)
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
console.log('✅ All projects already in intake round')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each project, create COMPLETED states for intake + any intermediate rounds
|
||||||
|
const toCreate: Array<{ projectId: string; roundId: string; state: 'COMPLETED' }> = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
// Find the earliest round this project is already in
|
||||||
|
const earliestSortOrder = project.projectRoundStates.length > 0
|
||||||
|
? Math.min(...project.projectRoundStates.map(ps => ps.round.sortOrder))
|
||||||
|
: Infinity
|
||||||
|
|
||||||
|
const existingRoundIds = new Set(project.projectRoundStates.map(ps => ps.roundId))
|
||||||
|
|
||||||
|
// Add COMPLETED for intake + all intermediate rounds before the earliest assigned round
|
||||||
|
for (const round of allRounds) {
|
||||||
|
if (round.sortOrder >= earliestSortOrder) break
|
||||||
|
if (existingRoundIds.has(round.id)) continue
|
||||||
|
|
||||||
|
toCreate.push({
|
||||||
|
projectId: project.id,
|
||||||
|
roundId: round.id,
|
||||||
|
state: 'COMPLETED',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Creating ${toCreate.length} ProjectRoundState records...`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.projectRoundState.createMany({
|
||||||
|
data: toCreate,
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary by round
|
||||||
|
const byRound = new Map<string, number>()
|
||||||
|
for (const r of toCreate) {
|
||||||
|
const name = allRounds.find(ar => ar.id === r.roundId)?.name ?? r.roundId
|
||||||
|
byRound.set(name, (byRound.get(name) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
for (const [name, count] of byRound) {
|
||||||
|
console.log(` ${name}: ${count} projects`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Done! ${toCreate.length} records ${dryRun ? 'would be' : ''} created`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Error:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
78
scripts/backfill-team-leads.ts
Normal file
78
scripts/backfill-team-leads.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Backfill TeamMember records for all projects that have a submittedByUserId
|
||||||
|
* but no corresponding TeamMember link.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/backfill-team-leads.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling team leads...\n')
|
||||||
|
|
||||||
|
// Find all projects with a submitter but no TeamMember link for that user
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
submittedByUserId: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
submittedByUserId: true,
|
||||||
|
teamMembers: {
|
||||||
|
select: { userId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let alreadyLinked = 0
|
||||||
|
let noSubmitter = 0
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
if (!project.submittedByUserId) {
|
||||||
|
noSubmitter++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyHasLink = project.teamMembers.some(
|
||||||
|
(tm) => tm.userId === project.submittedByUserId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (alreadyHasLink) {
|
||||||
|
alreadyLinked++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` + Linking "${project.title}" → user ${project.submittedByUserId}`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
userId: project.submittedByUserId,
|
||||||
|
role: 'LEAD',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Done!`)
|
||||||
|
console.log(` ${created} TeamMember records ${dryRun ? 'would be' : ''} created`)
|
||||||
|
console.log(` ${alreadyLinked} projects already had the submitter linked`)
|
||||||
|
console.log(` ${noSubmitter} projects had no submitter`)
|
||||||
|
console.log(` ${projects.length} total projects checked`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Error:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
32
scripts/check-invites.cjs
Normal file
32
scripts/check-invites.cjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const members = await p.teamMember.findMany({
|
||||||
|
orderBy: { joinedAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, status: true, inviteToken: true } },
|
||||||
|
project: { select: { title: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const m of members) {
|
||||||
|
console.log(m.role, '|', m.user.name, '|', m.user.email, '|', m.user.status, '|', m.project.title, '|', m.joinedAt.toISOString().slice(0,16), '| token:', m.user.inviteToken ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await p.notificationLog.findMany({
|
||||||
|
where: { type: 'TEAM_INVITATION' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
if (logs.length) {
|
||||||
|
console.log('\n--- Notification logs:');
|
||||||
|
for (const l of logs) {
|
||||||
|
console.log(l.status, '|', l.channel, '|', l.errorMsg, '|', l.createdAt.toISOString().slice(0,16));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('\n--- No TEAM_INVITATION notification logs found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
20
scripts/check-rounds.cjs
Normal file
20
scripts/check-rounds.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const rounds = await p.round.findMany({
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
select: { id: true, name: true, roundType: true, status: true, sortOrder: true, competitionId: true },
|
||||||
|
});
|
||||||
|
for (const r of rounds) console.log(r.sortOrder, '|', r.name, '|', r.roundType, '|', r.status, '|', r.id);
|
||||||
|
|
||||||
|
console.log('\n--- File Requirements:');
|
||||||
|
const reqs = await p.fileRequirement.findMany({ include: { round: { select: { name: true } } } });
|
||||||
|
for (const r of reqs) console.log(r.round.name, '|', r.name, '|', r.isRequired, '|', r.id);
|
||||||
|
|
||||||
|
console.log('\n--- Submission Windows:');
|
||||||
|
const wins = await p.submissionWindow.findMany({ select: { id: true, name: true, roundNumber: true, windowOpenAt: true, windowCloseAt: true, competitionId: true } });
|
||||||
|
for (const w of wins) console.log(w.name, '| round#', w.roundNumber, '| open:', w.windowOpenAt?.toISOString().slice(0,16), '| close:', w.windowCloseAt?.toISOString().slice(0,16));
|
||||||
|
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
71
scripts/create-requirements.cjs
Normal file
71
scripts/create-requirements.cjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// R2 - AI Screening round ID
|
||||||
|
const roundId = 'cmmafe7et00ldy53kxpdhhvf0';
|
||||||
|
|
||||||
|
// Check existing
|
||||||
|
const existing = await p.fileRequirement.count({ where: { roundId } });
|
||||||
|
if (existing > 0) {
|
||||||
|
console.log(`Round already has ${existing} file requirements, skipping.`);
|
||||||
|
await p.$disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirements = [
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Executive Summary',
|
||||||
|
description: 'A 2-page executive summary of your project (PDF format, max 10MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 10,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Business Plan',
|
||||||
|
description: 'Full business plan or project proposal (PDF format, max 25MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 25,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Pitch Presentation',
|
||||||
|
description: 'Slide deck presenting your project (PDF or PowerPoint, max 50MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||||||
|
maxSizeMB: 50,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Video Pitch',
|
||||||
|
description: 'A short video (max 3 minutes) explaining your project (MP4, max 200MB). Optional but recommended.',
|
||||||
|
acceptedMimeTypes: ['video/mp4', 'video/quicktime', 'video/webm'],
|
||||||
|
maxSizeMB: 200,
|
||||||
|
isRequired: false,
|
||||||
|
sortOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Supporting Documents',
|
||||||
|
description: 'Any additional supporting documents such as research papers, letters of support, etc. (PDF, max 20MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 20,
|
||||||
|
isRequired: false,
|
||||||
|
sortOrder: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const req of requirements) {
|
||||||
|
const created = await p.fileRequirement.create({ data: req });
|
||||||
|
console.log('Created:', created.name, '| required:', created.isRequired, '| id:', created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone! Created', requirements.length, 'file requirements for R2.');
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
68
scripts/create-test-applicant.ts
Normal file
68
scripts/create-test-applicant.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { sendInvitationEmail } from '../src/lib/email'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Find a program to attach the project to
|
||||||
|
const program = await prisma.program.findFirst()
|
||||||
|
if (!program) throw new Error('No program found - run seed first')
|
||||||
|
|
||||||
|
// Create applicant user
|
||||||
|
const inviteToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_applicant_matt_ciaccio',
|
||||||
|
name: 'Matt Ciaccio',
|
||||||
|
email: 'matt.ciaccio@gmail.com',
|
||||||
|
role: 'APPLICANT',
|
||||||
|
roles: ['APPLICANT'],
|
||||||
|
status: 'INVITED',
|
||||||
|
mustSetPassword: true,
|
||||||
|
inviteToken,
|
||||||
|
inviteTokenExpiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created user:', user.id)
|
||||||
|
|
||||||
|
// Create test project
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_project_qa',
|
||||||
|
title: 'OceanWatch AI',
|
||||||
|
description: 'AI-powered ocean monitoring platform for marine conservation',
|
||||||
|
programId: program.id,
|
||||||
|
submittedByUserId: user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created project:', project.id)
|
||||||
|
|
||||||
|
// Create team member (LEAD)
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_tm_lead',
|
||||||
|
projectId: project.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: 'LEAD',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created team member (LEAD)')
|
||||||
|
|
||||||
|
// Send styled invitation email
|
||||||
|
const url = `http://localhost:3000/accept-invite?token=${inviteToken}`
|
||||||
|
console.log('Invite URL:', url)
|
||||||
|
|
||||||
|
await sendInvitationEmail(
|
||||||
|
'matt.ciaccio@gmail.com',
|
||||||
|
'Matt Ciaccio',
|
||||||
|
url,
|
||||||
|
'APPLICANT',
|
||||||
|
72
|
||||||
|
)
|
||||||
|
console.log('Styled invitation email sent!')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect().then(() => process.exit(0)))
|
||||||
165
scripts/seed-notification-log.ts
Normal file
165
scripts/seed-notification-log.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Seed NotificationLog with confirmed SMTP delivery data.
|
||||||
|
*
|
||||||
|
* Sources:
|
||||||
|
* 1. 33 emails confirmed delivered in Poste.io SMTP logs (2026-03-04)
|
||||||
|
* 2. Users with status ACTIVE who are LEADs on PASSED projects
|
||||||
|
* (they clearly received and used their invite link)
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/seed-notification-log.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
// Emails confirmed delivered via SMTP logs on 2026-03-04
|
||||||
|
const CONFIRMED_SMTP_EMAILS = new Set([
|
||||||
|
'fbayong@balazstudio.com',
|
||||||
|
'gnoel@kilimora.africa',
|
||||||
|
'amal.chebbi@pigmentoco.com',
|
||||||
|
'nairita@yarsi.net',
|
||||||
|
'martin.itamalo@greenbrinetechnologies.com',
|
||||||
|
'petervegan1223@gmail.com',
|
||||||
|
'dmarinov@redget.io',
|
||||||
|
'adrien@seavium.com',
|
||||||
|
'l.buob@whisper-ef.com',
|
||||||
|
'silvia@omnivorus.com',
|
||||||
|
'marzettisebastian@gmail.com',
|
||||||
|
'fiona.mcomish@algae-scope.com',
|
||||||
|
'karimeguillen@rearvora.com',
|
||||||
|
'info@skywatt.tech',
|
||||||
|
'julia@nereia-coatings.com',
|
||||||
|
'info@janmaisenbacher.com',
|
||||||
|
'xbm_0201@qq.com',
|
||||||
|
'irinakharitonova0201@gmail.com',
|
||||||
|
'seablocksrecif@gmail.com',
|
||||||
|
'oscar@seafuser.com',
|
||||||
|
'charles.maher@blueshadow.dk',
|
||||||
|
'sabirabokhari@gmail.com',
|
||||||
|
'munayimbabura@gmail.com',
|
||||||
|
'amritha.ramadevu@edu.escp.eu',
|
||||||
|
'nele.jordan@myhsba.de',
|
||||||
|
'karl.mihhels@aalto.fi',
|
||||||
|
'christine.a.kurz@gmail.com',
|
||||||
|
'aki@corall.eco',
|
||||||
|
'topias.kilpinen@hotmail.fi',
|
||||||
|
'nina.riutta.camilla@gmail.com',
|
||||||
|
'sofie.boggiosella@my.jcu.edu.au',
|
||||||
|
'giambattistafigari@gmail.com',
|
||||||
|
'mussinig0@gmail.com',
|
||||||
|
])
|
||||||
|
|
||||||
|
const SENT_AT = new Date('2026-03-04T01:00:00Z')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '--- DRY RUN ---\n' : 'Seeding NotificationLog...\n')
|
||||||
|
|
||||||
|
// Find LEAD team members on PASSED projects
|
||||||
|
const passedLeads = await prisma.teamMember.findMany({
|
||||||
|
where: {
|
||||||
|
role: 'LEAD',
|
||||||
|
project: {
|
||||||
|
projectRoundStates: {
|
||||||
|
some: { state: 'PASSED' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
projectId: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
projectRoundStates: {
|
||||||
|
where: { state: 'PASSED' },
|
||||||
|
select: { roundId: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
status: true,
|
||||||
|
inviteToken: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Found ${passedLeads.length} LEAD team members on PASSED projects\n`)
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const lead of passedLeads) {
|
||||||
|
const email = lead.user.email?.toLowerCase()
|
||||||
|
if (!email) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a NotificationLog already exists for this project+email
|
||||||
|
const existing = await prisma.notificationLog.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
projectId: lead.projectId,
|
||||||
|
type: 'ADVANCEMENT_NOTIFICATION',
|
||||||
|
status: 'SENT',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine confidence of delivery
|
||||||
|
const isConfirmedSMTP = CONFIRMED_SMTP_EMAILS.has(email)
|
||||||
|
const isActive = lead.user.status === 'ACTIVE'
|
||||||
|
const isInvited = lead.user.status === 'INVITED' && !!lead.user.inviteToken
|
||||||
|
|
||||||
|
// Only seed for confirmed deliveries or active users
|
||||||
|
if (!isConfirmedSMTP && !isActive && !isInvited) {
|
||||||
|
console.log(` SKIP ${email} (status=${lead.user.status}, not in SMTP logs)`)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundId = lead.project.projectRoundStates[0]?.roundId ?? null
|
||||||
|
const label = isConfirmedSMTP ? 'SMTP-confirmed' : isActive ? 'user-active' : 'invite-sent'
|
||||||
|
|
||||||
|
console.log(` ${dryRun ? 'WOULD CREATE' : 'CREATE'} ${email} [${label}] project=${lead.projectId}`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: lead.user.id,
|
||||||
|
channel: 'EMAIL',
|
||||||
|
type: 'ADVANCEMENT_NOTIFICATION',
|
||||||
|
status: 'SENT',
|
||||||
|
email,
|
||||||
|
projectId: lead.projectId,
|
||||||
|
roundId,
|
||||||
|
batchId: 'seed-2026-03-04',
|
||||||
|
createdAt: SENT_AT,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
created++
|
||||||
|
} else {
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. Created: ${created}, Skipped: ${skipped}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Error:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
120
scripts/send-invite-direct.ts
Normal file
120
scripts/send-invite-direct.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
// Import just the template helper without hitting DB
|
||||||
|
// We'll construct the email manually since the DB connection fails
|
||||||
|
|
||||||
|
const BRAND = {
|
||||||
|
red: '#de0f1e',
|
||||||
|
darkBlue: '#053d57',
|
||||||
|
white: '#fefefe',
|
||||||
|
teal: '#557f8c',
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
|
||||||
|
const url = 'http://localhost:3000/accept-invite?token=' + token;
|
||||||
|
|
||||||
|
// Replicate the styled email template from email.ts
|
||||||
|
function getStyledHtml(name: string, inviteUrl: string) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>You're invited to join the MOPC Portal</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; width: 100%;">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, ${BRAND.teal} 100%); border-radius: 16px 16px 0 0; padding: 32px 40px; text-align: center;">
|
||||||
|
<h1 style="color: ${BRAND.white}; font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em;">
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
</h1>
|
||||||
|
<p style="color: rgba(255,255,255,0.8); font-size: 13px; font-weight: 300; margin: 8px 0 0 0; letter-spacing: 0.05em; text-transform: uppercase;">
|
||||||
|
Together for a healthier ocean
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #ffffff; padding: 40px; border-radius: 0 0 16px 16px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);">
|
||||||
|
<h2 style="color: ${BRAND.darkBlue}; font-size: 20px; font-weight: 600; margin: 0 0 24px 0;">
|
||||||
|
Hello ${name},
|
||||||
|
</h2>
|
||||||
|
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 16px 0; font-weight: 400;">
|
||||||
|
You've been invited to join the Monaco Ocean Protection Challenge platform as an <strong>applicant</strong>.
|
||||||
|
</p>
|
||||||
|
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 24px 0; font-weight: 400;">
|
||||||
|
Click the button below to set up your account and get started.
|
||||||
|
</p>
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 28px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="${inviteUrl}" style="display: inline-block; background: linear-gradient(135deg, ${BRAND.red} 0%, #c40d19 100%); color: #ffffff; text-decoration: none; padding: 14px 36px; border-radius: 10px; font-size: 15px; font-weight: 600; letter-spacing: 0.02em; box-shadow: 0 4px 14px rgba(222, 15, 30, 0.3);">
|
||||||
|
Accept Invitation
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!-- Info Box -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
|
<p style="color: #1e40af; margin: 0; font-size: 13px; line-height: 1.6;">
|
||||||
|
This link will expire in 3 days.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 24px 40px; text-align: center;">
|
||||||
|
<p style="color: #94a3b8; font-size: 12px; line-height: 1.6; margin: 0;">
|
||||||
|
Monaco Ocean Protection Challenge<br>
|
||||||
|
<span style="color: #cbd5e1;">Together for a healthier ocean.</span>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Creating transporter...');
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: 'mail.monaco-opc.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: 'noreply@monaco-opc.com',
|
||||||
|
pass: '9EythPDcz1Fya4M88iigkB1wojNf8QEVPuRRnD9dJMBpT3pk2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Sending styled invitation email...');
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: 'MOPC Portal <noreply@monaco-opc.com>',
|
||||||
|
to: 'matt.ciaccio@gmail.com',
|
||||||
|
subject: "You're invited to join the MOPC Portal",
|
||||||
|
text: `Hello Matt Ciaccio,\n\nYou've been invited to join the Monaco Ocean Protection Challenge platform as an applicant.\n\nClick the link below to set up your account:\n\n${url}\n\nThis link will expire in 3 days.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||||
|
html: getStyledHtml('Matt Ciaccio', url),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('SUCCESS! Message ID:', info.messageId);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('FAILED:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
26
scripts/send-invite.ts
Normal file
26
scripts/send-invite.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { sendInvitationEmail } from '../src/lib/email';
|
||||||
|
|
||||||
|
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
|
||||||
|
const url = 'http://localhost:3000/accept-invite?token=' + token;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Sending styled invitation email...');
|
||||||
|
console.log('To: matt.ciaccio@gmail.com');
|
||||||
|
console.log('URL:', url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendInvitationEmail(
|
||||||
|
'matt.ciaccio@gmail.com',
|
||||||
|
'Matt Ciaccio',
|
||||||
|
url,
|
||||||
|
'APPLICANT',
|
||||||
|
72
|
||||||
|
);
|
||||||
|
console.log('SUCCESS: Styled invitation email sent!');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('FAILED:', err.message || err);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
20
scripts/test-db.cjs
Normal file
20
scripts/test-db.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||||
|
const p = new PrismaClient({ log: ['query', 'info', 'warn', 'error'] });
|
||||||
|
try {
|
||||||
|
const result = await p.$queryRawUnsafe('SELECT 1 as ok');
|
||||||
|
console.log('Connected!', result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error code:', e.code);
|
||||||
|
console.error('Error meta:', JSON.stringify(e.meta, null, 2));
|
||||||
|
console.error('Message:', e.message);
|
||||||
|
} finally {
|
||||||
|
await p.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
382
src/components/admin/projects/bulk-notification-dialog.tsx
Normal file
382
src/components/admin/projects/bulk-notification-dialog.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Send,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Trophy,
|
||||||
|
Ban,
|
||||||
|
Award,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface BulkNotificationDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationDialogProps) {
|
||||||
|
// Section states
|
||||||
|
const [passedOpen, setPassedOpen] = useState(true)
|
||||||
|
const [rejectedOpen, setRejectedOpen] = useState(false)
|
||||||
|
const [awardOpen, setAwardOpen] = useState(false)
|
||||||
|
|
||||||
|
// Passed section
|
||||||
|
const [passedEnabled, setPassedEnabled] = useState(true)
|
||||||
|
const [passedMessage, setPassedMessage] = useState('')
|
||||||
|
const [passedFullCustom, setPassedFullCustom] = useState(false)
|
||||||
|
|
||||||
|
// Rejected section
|
||||||
|
const [rejectedEnabled, setRejectedEnabled] = useState(false)
|
||||||
|
const [rejectedMessage, setRejectedMessage] = useState('')
|
||||||
|
const [rejectedFullCustom, setRejectedFullCustom] = useState(false)
|
||||||
|
const [rejectedIncludeInvite, setRejectedIncludeInvite] = useState(false)
|
||||||
|
|
||||||
|
// Award section
|
||||||
|
const [selectedAwardId, setSelectedAwardId] = useState<string | null>(null)
|
||||||
|
const [awardMessage, setAwardMessage] = useState('')
|
||||||
|
|
||||||
|
// Global
|
||||||
|
const [skipAlreadySent, setSkipAlreadySent] = useState(true)
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
const [sendingPassed, setSendingPassed] = useState(false)
|
||||||
|
const [sendingRejected, setSendingRejected] = useState(false)
|
||||||
|
const [sendingAward, setSendingAward] = useState(false)
|
||||||
|
const [sendingAll, setSendingAll] = useState(false)
|
||||||
|
|
||||||
|
const summary = trpc.project.getBulkNotificationSummary.useQuery(undefined, {
|
||||||
|
enabled: open,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendPassed = trpc.project.sendBulkPassedNotifications.useMutation()
|
||||||
|
const sendRejected = trpc.project.sendBulkRejectionNotifications.useMutation()
|
||||||
|
const sendAward = trpc.project.sendBulkAwardNotifications.useMutation()
|
||||||
|
|
||||||
|
const handleSendPassed = async () => {
|
||||||
|
setSendingPassed(true)
|
||||||
|
try {
|
||||||
|
const result = await sendPassed.mutateAsync({
|
||||||
|
customMessage: passedMessage || undefined,
|
||||||
|
fullCustomBody: passedFullCustom,
|
||||||
|
skipAlreadySent,
|
||||||
|
})
|
||||||
|
toast.success(`Advancement: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
|
||||||
|
summary.refetch()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to send')
|
||||||
|
} finally {
|
||||||
|
setSendingPassed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendRejected = async () => {
|
||||||
|
setSendingRejected(true)
|
||||||
|
try {
|
||||||
|
const result = await sendRejected.mutateAsync({
|
||||||
|
customMessage: rejectedMessage || undefined,
|
||||||
|
fullCustomBody: rejectedFullCustom,
|
||||||
|
includeInviteLink: rejectedIncludeInvite,
|
||||||
|
skipAlreadySent,
|
||||||
|
})
|
||||||
|
toast.success(`Rejection: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
|
||||||
|
summary.refetch()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to send')
|
||||||
|
} finally {
|
||||||
|
setSendingRejected(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendAward = async (awardId: string) => {
|
||||||
|
setSendingAward(true)
|
||||||
|
try {
|
||||||
|
const result = await sendAward.mutateAsync({
|
||||||
|
awardId,
|
||||||
|
customMessage: awardMessage || undefined,
|
||||||
|
skipAlreadySent,
|
||||||
|
})
|
||||||
|
toast.success(`Award: ${result.sent} sent, ${result.failed} failed`)
|
||||||
|
summary.refetch()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to send')
|
||||||
|
} finally {
|
||||||
|
setSendingAward(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendAll = async () => {
|
||||||
|
setSendingAll(true)
|
||||||
|
try {
|
||||||
|
if (passedEnabled && totalPassed > 0) {
|
||||||
|
await handleSendPassed()
|
||||||
|
}
|
||||||
|
if (rejectedEnabled && (summary.data?.rejected.count ?? 0) > 0) {
|
||||||
|
await handleSendRejected()
|
||||||
|
}
|
||||||
|
toast.success('All enabled notifications sent')
|
||||||
|
} catch {
|
||||||
|
// Individual handlers already toast errors
|
||||||
|
} finally {
|
||||||
|
setSendingAll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPassed = summary.data?.passed.reduce((sum, g) => sum + g.projectCount, 0) ?? 0
|
||||||
|
const isSending = sendingPassed || sendingRejected || sendingAward || sendingAll
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk Notifications</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Send advancement, rejection, and award pool notifications to project teams.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{summary.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : summary.error ? (
|
||||||
|
<div className="text-destructive text-sm py-4">
|
||||||
|
Failed to load summary: {summary.error.message}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Global settings */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="skip-already-sent"
|
||||||
|
checked={skipAlreadySent}
|
||||||
|
onCheckedChange={setSkipAlreadySent}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="skip-already-sent" className="text-sm">
|
||||||
|
Skip already notified
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{summary.data?.alreadyNotified.advancement ?? 0} advancement + {summary.data?.alreadyNotified.rejection ?? 0} rejection already sent
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PASSED section */}
|
||||||
|
<Collapsible open={passedOpen} onOpenChange={setPassedOpen}>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{passedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
<Trophy className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="font-medium">Passed / Advanced</span>
|
||||||
|
<Badge variant="secondary">{totalPassed} projects</Badge>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={passedEnabled}
|
||||||
|
onCheckedChange={setPassedEnabled}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||||
|
{summary.data?.passed.map((g) => (
|
||||||
|
<div key={g.roundId} className="text-sm flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
<span className="text-muted-foreground">{g.roundName}</span>
|
||||||
|
<span className="font-medium">{g.projectCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">→ {g.nextRoundName}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label className="text-xs">Custom message (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={passedMessage}
|
||||||
|
onChange={(e) => setPassedMessage(e.target.value)}
|
||||||
|
placeholder="Add a personal note to the advancement email..."
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="passed-full-custom"
|
||||||
|
checked={passedFullCustom}
|
||||||
|
onCheckedChange={setPassedFullCustom}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="passed-full-custom" className="text-xs">
|
||||||
|
Full custom body (replace default template)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSendPassed}
|
||||||
|
disabled={!passedEnabled || totalPassed === 0 || isSending}
|
||||||
|
>
|
||||||
|
{sendingPassed ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
|
||||||
|
Send Advancement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* REJECTED section */}
|
||||||
|
<Collapsible open={rejectedOpen} onOpenChange={setRejectedOpen}>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{rejectedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
<Ban className="h-4 w-4 text-red-600" />
|
||||||
|
<span className="font-medium">Rejected / Filtered Out</span>
|
||||||
|
<Badge variant="destructive">{summary.data?.rejected.count ?? 0} projects</Badge>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={rejectedEnabled}
|
||||||
|
onCheckedChange={setRejectedEnabled}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Custom message (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={rejectedMessage}
|
||||||
|
onChange={(e) => setRejectedMessage(e.target.value)}
|
||||||
|
placeholder="Add a personal note to the rejection email..."
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="rejected-full-custom"
|
||||||
|
checked={rejectedFullCustom}
|
||||||
|
onCheckedChange={setRejectedFullCustom}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="rejected-full-custom" className="text-xs">
|
||||||
|
Full custom body (replace default template)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="rejected-include-invite"
|
||||||
|
checked={rejectedIncludeInvite}
|
||||||
|
onCheckedChange={setRejectedIncludeInvite}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="rejected-include-invite" className="text-xs">
|
||||||
|
Include platform invite link for rejected teams
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleSendRejected}
|
||||||
|
disabled={!rejectedEnabled || (summary.data?.rejected.count ?? 0) === 0 || isSending}
|
||||||
|
>
|
||||||
|
{sendingRejected ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
|
||||||
|
Send Rejections
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* AWARD POOLS section */}
|
||||||
|
<Collapsible open={awardOpen} onOpenChange={setAwardOpen}>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{awardOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
<Award className="h-4 w-4 text-amber-600" />
|
||||||
|
<span className="font-medium">Award Pools</span>
|
||||||
|
<Badge variant="outline">{summary.data?.awardPools.length ?? 0} awards</Badge>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||||
|
{(summary.data?.awardPools ?? []).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No award pools configured.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{summary.data?.awardPools.map((a) => (
|
||||||
|
<div key={a.awardId} className="flex items-center justify-between rounded border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Award className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
<span className="text-sm font-medium">{a.awardName}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">{a.eligibleCount} eligible</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAwardId(a.awardId)
|
||||||
|
handleSendAward(a.awardId)
|
||||||
|
}}
|
||||||
|
disabled={a.eligibleCount === 0 || isSending}
|
||||||
|
>
|
||||||
|
{sendingAward && selectedAwardId === a.awardId ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Notify
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<Label className="text-xs">Custom message for awards (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={awardMessage}
|
||||||
|
onChange={(e) => setAwardMessage(e.target.value)}
|
||||||
|
placeholder="Add a note to the award notification..."
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Send All button */}
|
||||||
|
<div className="flex justify-end pt-2 border-t">
|
||||||
|
<Button
|
||||||
|
onClick={handleSendAll}
|
||||||
|
disabled={(!passedEnabled && !rejectedEnabled) || isSending}
|
||||||
|
>
|
||||||
|
{sendingAll ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||||
|
Send All Enabled
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,12 +6,20 @@ import { prisma } from '@/lib/prisma'
|
|||||||
let cachedTransporter: Transporter | null = null
|
let cachedTransporter: Transporter | null = null
|
||||||
let cachedConfigHash = ''
|
let cachedConfigHash = ''
|
||||||
let cachedFrom = ''
|
let cachedFrom = ''
|
||||||
|
let cachedAt = 0
|
||||||
|
const CACHE_TTL = 60_000 // 1 minute
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get SMTP transporter using database settings with env var fallback.
|
* Get SMTP transporter using database settings with env var fallback.
|
||||||
* Caches the transporter and rebuilds it when settings change.
|
* Caches the transporter and rebuilds it when settings change.
|
||||||
|
* Uses connection pooling for reliable bulk sends.
|
||||||
*/
|
*/
|
||||||
async function getTransporter(): Promise<{ transporter: Transporter; from: string }> {
|
async function getTransporter(): Promise<{ transporter: Transporter; from: string }> {
|
||||||
|
// Fast path: return cached transporter if still fresh
|
||||||
|
if (cachedTransporter && Date.now() - cachedAt < CACHE_TTL) {
|
||||||
|
return { transporter: cachedTransporter, from: cachedFrom }
|
||||||
|
}
|
||||||
|
|
||||||
// Read DB settings
|
// Read DB settings
|
||||||
const dbSettings = await prisma.systemSettings.findMany({
|
const dbSettings = await prisma.systemSettings.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -43,22 +51,42 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
|
|||||||
// Check if config changed since last call
|
// Check if config changed since last call
|
||||||
const configHash = `${host}:${port}:${user}:${pass}:${from}`
|
const configHash = `${host}:${port}:${user}:${pass}:${from}`
|
||||||
if (cachedTransporter && configHash === cachedConfigHash) {
|
if (cachedTransporter && configHash === cachedConfigHash) {
|
||||||
|
cachedAt = Date.now()
|
||||||
return { transporter: cachedTransporter, from: cachedFrom }
|
return { transporter: cachedTransporter, from: cachedFrom }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new transporter
|
// Close old transporter if it exists (clean up pooled connections)
|
||||||
|
if (cachedTransporter) {
|
||||||
|
try { cachedTransporter.close() } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new transporter with connection pooling for reliable bulk sends
|
||||||
cachedTransporter = nodemailer.createTransport({
|
cachedTransporter = nodemailer.createTransport({
|
||||||
host,
|
host,
|
||||||
port: parseInt(port),
|
port: parseInt(port),
|
||||||
secure: port === '465',
|
secure: port === '465',
|
||||||
auth: { user, pass },
|
auth: { user, pass },
|
||||||
})
|
pool: true,
|
||||||
|
maxConnections: 5,
|
||||||
|
maxMessages: 10,
|
||||||
|
socketTimeout: 30_000,
|
||||||
|
connectionTimeout: 15_000,
|
||||||
|
} as nodemailer.TransportOptions)
|
||||||
cachedConfigHash = configHash
|
cachedConfigHash = configHash
|
||||||
cachedFrom = from
|
cachedFrom = from
|
||||||
|
cachedAt = Date.now()
|
||||||
|
|
||||||
return { transporter: cachedTransporter, from: cachedFrom }
|
return { transporter: cachedTransporter, from: cachedFrom }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay helper for throttling bulk email sends.
|
||||||
|
* Prevents overwhelming the SMTP server (Poste.io).
|
||||||
|
*/
|
||||||
|
export function emailDelay(ms = 150): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy references for backward compat — default sender from env
|
// Legacy references for backward compat — default sender from env
|
||||||
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
|
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
|
||||||
|
|
||||||
@@ -1688,9 +1716,34 @@ export function getAdvancementNotificationTemplate(
|
|||||||
toRoundName: string,
|
toRoundName: string,
|
||||||
customMessage?: string,
|
customMessage?: string,
|
||||||
accountUrl?: string,
|
accountUrl?: string,
|
||||||
|
fullCustomBody?: boolean,
|
||||||
): EmailTemplate {
|
): EmailTemplate {
|
||||||
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
||||||
|
|
||||||
|
const escapedMessage = customMessage
|
||||||
|
? customMessage
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Full custom body mode: only the custom message inside the branded wrapper
|
||||||
|
if (fullCustomBody && escapedMessage) {
|
||||||
|
const content = `
|
||||||
|
${sectionTitle(greeting)}
|
||||||
|
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
||||||
|
${accountUrl
|
||||||
|
? ctaButton(accountUrl, 'Create Your Account')
|
||||||
|
: ctaButton('/applicant', 'View Your Dashboard')}
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
subject: `Your project has advanced: "${projectName}"`,
|
||||||
|
html: getEmailWrapper(content),
|
||||||
|
text: `${greeting}\n\n${customMessage}\n\n${accountUrl ? `Create your account: ${getBaseUrl()}${accountUrl}` : `Visit your dashboard: ${getBaseUrl()}/applicant`}\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const celebrationBanner = `
|
const celebrationBanner = `
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1702,14 +1755,6 @@ export function getAdvancementNotificationTemplate(
|
|||||||
</table>
|
</table>
|
||||||
`
|
`
|
||||||
|
|
||||||
const escapedMessage = customMessage
|
|
||||||
? customMessage
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
: null
|
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${celebrationBanner}
|
${celebrationBanner}
|
||||||
@@ -1757,7 +1802,8 @@ export function getRejectionNotificationTemplate(
|
|||||||
name: string,
|
name: string,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
roundName: string,
|
roundName: string,
|
||||||
customMessage?: string
|
customMessage?: string,
|
||||||
|
fullCustomBody?: boolean,
|
||||||
): EmailTemplate {
|
): EmailTemplate {
|
||||||
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
||||||
|
|
||||||
@@ -1769,6 +1815,22 @@ export function getRejectionNotificationTemplate(
|
|||||||
.replace(/\n/g, '<br>')
|
.replace(/\n/g, '<br>')
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// Full custom body mode: only the custom message inside the branded wrapper
|
||||||
|
if (fullCustomBody && escapedMessage) {
|
||||||
|
const content = `
|
||||||
|
${sectionTitle(greeting)}
|
||||||
|
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
||||||
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
||||||
|
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
subject: `Update on your application: "${projectName}"`,
|
||||||
|
html: getEmailWrapper(content),
|
||||||
|
text: `${greeting}\n\n${customMessage}\n\nThank you for being part of the Monaco Ocean Protection Challenge community.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Thank you for your participation in <strong>${roundName}</strong> with your project <strong>"${projectName}"</strong>.`)}
|
${paragraph(`Thank you for your participation in <strong>${roundName}</strong> with your project <strong>"${projectName}"</strong>.`)}
|
||||||
@@ -2055,13 +2117,15 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
|||||||
(ctx.metadata?.toRoundName as string) || 'next round',
|
(ctx.metadata?.toRoundName as string) || 'next round',
|
||||||
ctx.metadata?.customMessage as string | undefined,
|
ctx.metadata?.customMessage as string | undefined,
|
||||||
ctx.metadata?.accountUrl as string | undefined,
|
ctx.metadata?.accountUrl as string | undefined,
|
||||||
|
ctx.metadata?.fullCustomBody as boolean | undefined,
|
||||||
),
|
),
|
||||||
REJECTION_NOTIFICATION: (ctx) =>
|
REJECTION_NOTIFICATION: (ctx) =>
|
||||||
getRejectionNotificationTemplate(
|
getRejectionNotificationTemplate(
|
||||||
ctx.name || '',
|
ctx.name || '',
|
||||||
(ctx.metadata?.projectName as string) || 'Your Project',
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
||||||
(ctx.metadata?.roundName as string) || 'this round',
|
(ctx.metadata?.roundName as string) || 'this round',
|
||||||
ctx.metadata?.customMessage as string | undefined
|
ctx.metadata?.customMessage as string | undefined,
|
||||||
|
ctx.metadata?.fullCustomBody as boolean | undefined,
|
||||||
),
|
),
|
||||||
|
|
||||||
AWARD_SELECTION_NOTIFICATION: (ctx) =>
|
AWARD_SELECTION_NOTIFICATION: (ctx) =>
|
||||||
|
|||||||
97
src/server/services/notification-sender.ts
Normal file
97
src/server/services/notification-sender.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { sendStyledNotificationEmail, emailDelay } from '@/lib/email'
|
||||||
|
import type { NotificationEmailContext } from '@/lib/email'
|
||||||
|
|
||||||
|
export type NotificationItem = {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
type: string // ADVANCEMENT_NOTIFICATION, REJECTION_NOTIFICATION, etc.
|
||||||
|
context: NotificationEmailContext
|
||||||
|
projectId?: string
|
||||||
|
userId?: string
|
||||||
|
roundId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BatchResult = {
|
||||||
|
sent: number
|
||||||
|
failed: number
|
||||||
|
batchId: string
|
||||||
|
errors: Array<{ email: string; error: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notifications in batches with throttling and per-email logging.
|
||||||
|
* Each email is logged to NotificationLog with SENT or FAILED status.
|
||||||
|
*/
|
||||||
|
export async function sendBatchNotifications(
|
||||||
|
items: NotificationItem[],
|
||||||
|
options?: { batchSize?: number; batchDelayMs?: number }
|
||||||
|
): Promise<BatchResult> {
|
||||||
|
const batchId = randomUUID()
|
||||||
|
const batchSize = options?.batchSize ?? 10
|
||||||
|
const batchDelayMs = options?.batchDelayMs ?? 500
|
||||||
|
|
||||||
|
let sent = 0
|
||||||
|
let failed = 0
|
||||||
|
const errors: Array<{ email: string; error: string }> = []
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += batchSize) {
|
||||||
|
const chunk = items.slice(i, i + batchSize)
|
||||||
|
|
||||||
|
for (const item of chunk) {
|
||||||
|
try {
|
||||||
|
await sendStyledNotificationEmail(
|
||||||
|
item.email,
|
||||||
|
item.name,
|
||||||
|
item.type,
|
||||||
|
item.context,
|
||||||
|
)
|
||||||
|
sent++
|
||||||
|
|
||||||
|
// Log success (fire-and-forget)
|
||||||
|
prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: item.userId || null,
|
||||||
|
channel: 'EMAIL',
|
||||||
|
type: item.type,
|
||||||
|
status: 'SENT',
|
||||||
|
email: item.email,
|
||||||
|
roundId: item.roundId || null,
|
||||||
|
projectId: item.projectId || null,
|
||||||
|
batchId,
|
||||||
|
},
|
||||||
|
}).catch((err) => console.error('[notification-sender] Log write failed:', err))
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||||
|
failed++
|
||||||
|
errors.push({ email: item.email, error: errorMsg })
|
||||||
|
console.error(`[notification-sender] Failed for ${item.email}:`, err)
|
||||||
|
|
||||||
|
// Log failure (fire-and-forget)
|
||||||
|
prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: item.userId || null,
|
||||||
|
channel: 'EMAIL',
|
||||||
|
type: item.type,
|
||||||
|
status: 'FAILED',
|
||||||
|
email: item.email,
|
||||||
|
roundId: item.roundId || null,
|
||||||
|
projectId: item.projectId || null,
|
||||||
|
batchId,
|
||||||
|
errorMsg,
|
||||||
|
},
|
||||||
|
}).catch((logErr) => console.error('[notification-sender] Log write failed:', logErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
await emailDelay()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay between chunks to avoid overwhelming SMTP
|
||||||
|
if (i + batchSize < items.length) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, batchDelayMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent, failed, batchId, errors }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user