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} +
+ ))} +
+ +