From f24bea3df2b10dbca3ae92dadac63980d4140f9d Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 4 Mar 2026 13:29:06 +0100 Subject: [PATCH] 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 --- .../migration.sql | 31 ++ prisma/schema.prisma | 22 +- scripts/backfill-intake-round.ts | 112 +++++ scripts/backfill-team-leads.ts | 78 ++++ scripts/check-invites.cjs | 32 ++ scripts/check-rounds.cjs | 20 + scripts/create-requirements.cjs | 71 ++++ scripts/create-test-applicant.ts | 68 ++++ scripts/seed-notification-log.ts | 165 ++++++++ scripts/send-invite-direct.ts | 120 ++++++ scripts/send-invite.ts | 26 ++ scripts/test-db.cjs | 20 + .../projects/bulk-notification-dialog.tsx | 382 ++++++++++++++++++ src/lib/email.ts | 88 +++- src/server/services/notification-sender.ts | 97 +++++ 15 files changed, 1316 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/20260304111008_extend_notification_log/migration.sql create mode 100644 scripts/backfill-intake-round.ts create mode 100644 scripts/backfill-team-leads.ts create mode 100644 scripts/check-invites.cjs create mode 100644 scripts/check-rounds.cjs create mode 100644 scripts/create-requirements.cjs create mode 100644 scripts/create-test-applicant.ts create mode 100644 scripts/seed-notification-log.ts create mode 100644 scripts/send-invite-direct.ts create mode 100644 scripts/send-invite.ts create mode 100644 scripts/test-db.cjs create mode 100644 src/components/admin/projects/bulk-notification-dialog.tsx create mode 100644 src/server/services/notification-sender.ts diff --git a/prisma/migrations/20260304111008_extend_notification_log/migration.sql b/prisma/migrations/20260304111008_extend_notification_log/migration.sql new file mode 100644 index 0000000..b2b7a85 --- /dev/null +++ b/prisma/migrations/20260304111008_extend_notification_log/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a5355c4..d788d28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -638,6 +638,7 @@ model Project { deliberationVotes DeliberationVote[] deliberationResults DeliberationResult[] submissionPromotions SubmissionPromotionEvent[] + notificationLogs NotificationLog[] @@index([programId]) @@index([status]) @@ -931,22 +932,34 @@ model AIUsageLog { model NotificationLog { id String @id @default(cuid()) - userId String - channel NotificationChannel + userId String? + channel NotificationChannel @default(EMAIL) 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 externalId String? // Message ID from provider 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()) // 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([status]) @@index([createdAt]) + @@index([roundId, type]) + @@index([projectId]) + @@index([batchId]) + @@index([email]) } // ============================================================================= @@ -2233,6 +2246,7 @@ model Round { evaluationSummaries EvaluationSummary[] evaluationDiscussions EvaluationDiscussion[] messages Message[] + notificationLogs NotificationLog[] cohorts Cohort[] liveCursor LiveProgressCursor? diff --git a/scripts/backfill-intake-round.ts b/scripts/backfill-intake-round.ts new file mode 100644 index 0000000..88438bf --- /dev/null +++ b/scripts/backfill-intake-round.ts @@ -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() + 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()) diff --git a/scripts/backfill-team-leads.ts b/scripts/backfill-team-leads.ts new file mode 100644 index 0000000..073f863 --- /dev/null +++ b/scripts/backfill-team-leads.ts @@ -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()) diff --git a/scripts/check-invites.cjs b/scripts/check-invites.cjs new file mode 100644 index 0000000..5792b56 --- /dev/null +++ b/scripts/check-invites.cjs @@ -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(); +})(); diff --git a/scripts/check-rounds.cjs b/scripts/check-rounds.cjs new file mode 100644 index 0000000..59ad781 --- /dev/null +++ b/scripts/check-rounds.cjs @@ -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(); +})(); diff --git a/scripts/create-requirements.cjs b/scripts/create-requirements.cjs new file mode 100644 index 0000000..b391d1d --- /dev/null +++ b/scripts/create-requirements.cjs @@ -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(); +})(); diff --git a/scripts/create-test-applicant.ts b/scripts/create-test-applicant.ts new file mode 100644 index 0000000..ee56de0 --- /dev/null +++ b/scripts/create-test-applicant.ts @@ -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))) diff --git a/scripts/seed-notification-log.ts b/scripts/seed-notification-log.ts new file mode 100644 index 0000000..5cfbf15 --- /dev/null +++ b/scripts/seed-notification-log.ts @@ -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()) diff --git a/scripts/send-invite-direct.ts b/scripts/send-invite-direct.ts new file mode 100644 index 0000000..ac9b660 --- /dev/null +++ b/scripts/send-invite-direct.ts @@ -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 ` + + + + +You're invited to join the MOPC Portal + + + + + + +
+ + + + + + + + + + + + + +
+

+ Monaco Ocean Protection Challenge +

+

+ Together for a healthier ocean +

+
+

+ Hello ${name}, +

+

+ You've been invited to join the Monaco Ocean Protection Challenge platform as an applicant. +

+

+ Click the button below to set up your account and get started. +

+ + + + + +
+ + Accept Invitation + +
+ + + + + +
+

+ This link will expire in 3 days. +

+
+
+

+ Monaco Ocean Protection Challenge
+ Together for a healthier ocean. +

+
+
+ +`; +} + +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 ', + 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); +}); diff --git a/scripts/send-invite.ts b/scripts/send-invite.ts new file mode 100644 index 0000000..fd89f3a --- /dev/null +++ b/scripts/send-invite.ts @@ -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(); diff --git a/scripts/test-db.cjs b/scripts/test-db.cjs new file mode 100644 index 0000000..5b08705 --- /dev/null +++ b/scripts/test-db.cjs @@ -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(); diff --git a/src/components/admin/projects/bulk-notification-dialog.tsx b/src/components/admin/projects/bulk-notification-dialog.tsx new file mode 100644 index 0000000..bc04d2e --- /dev/null +++ b/src/components/admin/projects/bulk-notification-dialog.tsx @@ -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(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 ( + + + + Bulk Notifications + + Send advancement, rejection, and award pool notifications to project teams. + + + + {summary.isLoading ? ( +
+ +
+ ) : summary.error ? ( +
+ Failed to load summary: {summary.error.message} +
+ ) : ( +
+ {/* Global settings */} +
+
+ + +
+
+ {summary.data?.alreadyNotified.advancement ?? 0} advancement + {summary.data?.alreadyNotified.rejection ?? 0} rejection already sent +
+
+ + {/* PASSED section */} + +
+ +
+ {passedOpen ? : } + + Passed / Advanced + {totalPassed} projects +
+ e.stopPropagation()} + /> +
+ +
+ {summary.data?.passed.map((g) => ( +
+ + {g.roundName} + {g.projectCount} + → {g.nextRoundName} +
+ ))} +
+ +