Compare commits

...

4 Commits

Author SHA1 Message Date
267d26581d feat: resolve project logo URLs server-side, show logos in admin + observer
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m30s
Add attachProjectLogoUrls utility mirroring avatar URL pattern. Pipe
project.list and analytics.getAllProjects through logo URL resolver so
ProjectLogo components receive presigned URLs. Add logos to observer
projects table and mobile cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:29:54 +01:00
a39e27f6ff fix: applicant portal — document uploads, round filtering, auth hardening
Fix round-specific document uploads (submittedAt no longer blocks uploads),
add view/download buttons for existing files, enforce active-round-only for
uploads/deletes. Harden auth layout and set-password page. Filter applicant
portal rounds by award track membership.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:29:39 +01:00
1103d42439 feat: admin UX improvements — notify buttons, eval config, round finalization
Custom body support for advancement/rejection notification emails, evaluation
config toggle fix, user actions improvements, round finalization with reorder
support, project detail page enhancements, award pool duplicate prevention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:29:22 +01:00
f24bea3df2 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>
2026-03-04 13:29:06 +01:00
39 changed files with 3033 additions and 359 deletions

View File

@@ -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;

View File

@@ -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?

View 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())

View 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
View 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
View 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();
})();

View 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();
})();

View 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)))

View 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())

View 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
View 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
View 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();

View File

@@ -23,6 +23,29 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { FileViewer } from '@/components/shared/file-viewer'
import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
@@ -37,7 +60,6 @@ import {
Users,
FileText,
Calendar,
Clock,
BarChart3,
ThumbsUp,
ThumbsDown,
@@ -50,9 +72,11 @@ import {
Loader2,
ScanSearch,
Eye,
Plus,
X,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate, formatDateOnly } from '@/lib/utils'
import { formatDateOnly } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
@@ -121,6 +145,42 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
// State for add member dialog
const [addMemberOpen, setAddMemberOpen] = useState(false)
const [addMemberForm, setAddMemberForm] = useState({
email: '',
name: '',
role: 'MEMBER' as 'LEAD' | 'MEMBER' | 'ADVISOR',
title: '',
sendInvite: true,
})
// State for remove member confirmation
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
const addTeamMember = trpc.project.addTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member added')
setAddMemberOpen(false)
setAddMemberForm({ email: '', name: '', role: 'MEMBER', title: '', sendInvite: true })
utils.project.getFullDetail.invalidate({ id: projectId })
},
onError: (err) => {
toast.error(err.message || 'Failed to add team member')
},
})
const removeTeamMember = trpc.project.removeTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member removed')
setRemovingMemberId(null)
utils.project.getFullDetail.invalidate({ id: projectId })
},
onError: (err) => {
toast.error(err.message || 'Failed to remove team member')
},
})
if (isLoading) {
return <ProjectDetailSkeleton />
}
@@ -184,9 +244,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
<Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}>
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
</Badge>
{(() => {
const prs = (project as any).projectRoundStates ?? []
if (!prs.length) return <Badge variant="secondary">Submitted</Badge>
if (prs.some((p: any) => p.state === 'REJECTED')) return <Badge variant="destructive">Rejected</Badge>
const latest = prs[0]
return <Badge variant={latest.state === 'PASSED' ? 'default' : 'secondary'}>{latest.round.name}</Badge>
})()}
</div>
{project.teamName && (
<p className="text-muted-foreground">{project.teamName}</p>
@@ -430,53 +494,203 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</AnimatedCard>
{/* Team Members Section */}
{project.teamMembers && project.teamMembers.length > 0 && (
<AnimatedCard index={2}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
Team Members ({project.teamMembers.length})
</CardTitle>
</div>
</CardHeader>
<CardContent>
<AnimatedCard index={2}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
Team Members ({project.teamMembers?.length ?? 0})
</CardTitle>
<Button variant="outline" size="sm" onClick={() => setAddMemberOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Member
</Button>
</div>
</CardHeader>
<CardContent>
{project.teamMembers && project.teamMembers.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => (
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
{member.role === 'LEAD' ? (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<Crown className="h-5 w-5 text-yellow-500" />
</div>
) : (
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">
{member.user.name || 'Unnamed'}
</p>
<Badge variant="outline" className="text-xs">
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{member.user.email}
</p>
{member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p>
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => {
const isLastLead =
member.role === 'LEAD' &&
project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1
return (
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
{member.role === 'LEAD' ? (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
<Crown className="h-5 w-5 text-yellow-500" />
</div>
) : (
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">
{member.user.name || 'Unnamed'}
</p>
<Badge variant="outline" className="text-xs">
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{member.user.email}
</p>
{member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p>
)}
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
disabled={isLastLead}
onClick={() => setRemovingMemberId(member.user.id)}
>
<X className="h-4 w-4" />
</Button>
</span>
</TooltipTrigger>
{isLastLead && (
<TooltipContent>
Cannot remove the last team lead
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
</div>
))}
)
})}
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
) : (
<p className="text-sm text-muted-foreground">No team members yet.</p>
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Add Member Dialog */}
<Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add Team Member</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="member-email">Email</Label>
<Input
id="member-email"
type="email"
placeholder="member@example.com"
value={addMemberForm.email}
onChange={(e) => setAddMemberForm((f) => ({ ...f, email: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-name">Name</Label>
<Input
id="member-name"
placeholder="Full name"
value={addMemberForm.name}
onChange={(e) => setAddMemberForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-role">Role</Label>
<Select
value={addMemberForm.role}
onValueChange={(v) => setAddMemberForm((f) => ({ ...f, role: v as 'LEAD' | 'MEMBER' | 'ADVISOR' }))}
>
<SelectTrigger id="member-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEAD">Lead</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="member-title">Title (optional)</Label>
<Input
id="member-title"
placeholder="e.g. CEO, Co-founder"
value={addMemberForm.title}
onChange={(e) => setAddMemberForm((f) => ({ ...f, title: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="member-invite"
checked={addMemberForm.sendInvite}
onCheckedChange={(checked) =>
setAddMemberForm((f) => ({ ...f, sendInvite: checked === true }))
}
/>
<Label htmlFor="member-invite" className="font-normal cursor-pointer">
Send invite email
</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddMemberOpen(false)}>
Cancel
</Button>
<Button
onClick={() =>
addTeamMember.mutate({
projectId,
email: addMemberForm.email,
name: addMemberForm.name,
role: addMemberForm.role,
title: addMemberForm.title || undefined,
sendInvite: addMemberForm.sendInvite,
})
}
disabled={addTeamMember.isPending || !addMemberForm.email || !addMemberForm.name}
>
{addTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Member
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Remove Member Confirmation Dialog */}
<Dialog open={!!removingMemberId} onOpenChange={(open) => { if (!open) setRemovingMemberId(null) }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Remove Team Member</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Are you sure you want to remove this team member? This action cannot be undone.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setRemovingMemberId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (removingMemberId) {
removeTeamMember.mutate({ projectId, userId: removingMemberId })
}
}}
disabled={removeTeamMember.isPending}
>
{removeTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Remove
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Mentor Assignment Section */}
{project.wantsMentorship && (

View File

@@ -72,6 +72,7 @@ import {
ArrowRightCircle,
LayoutGrid,
LayoutList,
Bell,
} from 'lucide-react'
import {
Select,
@@ -90,7 +91,8 @@ import {
} from '@/components/ui/tooltip'
import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
import { StatusBadge } from '@/components/shared/status-badge'
import { BulkNotificationDialog } from '@/components/admin/projects/bulk-notification-dialog'
import { Pagination } from '@/components/shared/pagination'
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
import { CountryFlagImg } from '@/components/ui/country-select'
@@ -113,6 +115,25 @@ const statusColors: Record<
WINNER: 'success',
REJECTED: 'destructive',
WITHDRAWN: 'secondary',
// Round-state-based statuses
PENDING: 'secondary',
IN_PROGRESS: 'default',
COMPLETED: 'default',
PASSED: 'success',
}
type ProjectRoundStateInfo = {
state: string
round: { name: string; sortOrder: number }
}
function deriveProjectStatus(prs: ProjectRoundStateInfo[]): { label: string; variant: 'default' | 'success' | 'secondary' | 'destructive' | 'warning' } {
if (!prs.length) return { label: 'Submitted', variant: 'secondary' }
if (prs.some((p) => p.state === 'REJECTED')) return { label: 'Rejected', variant: 'destructive' }
// prs is already sorted by sortOrder desc — first item is the latest round
const latest = prs[0]
if (latest.state === 'PASSED') return { label: latest.round.name, variant: 'success' }
return { label: latest.round.name, variant: 'default' }
}
function parseFiltersFromParams(
@@ -290,6 +311,7 @@ export default function ProjectsPage() {
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
const [assignRoundId, setAssignRoundId] = useState('')
const [bulkNotifyOpen, setBulkNotifyOpen] = useState(false)
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
@@ -619,6 +641,13 @@ export default function ProjectsPage() {
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => setBulkNotifyOpen(true)}
>
<Bell className="mr-2 h-4 w-4" />
Send Notifications
</Button>
<Button
variant="outline"
onClick={() => setAiTagDialogOpen(true)}
@@ -713,7 +742,7 @@ export default function ProjectsPage() {
<div className="flex flex-wrap items-center gap-2 text-sm">
{Object.entries(data.statusCounts ?? {})
.sort(([a], [b]) => {
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
const order = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
})
.map(([status, count]) => (
@@ -873,7 +902,7 @@ export default function ProjectsPage() {
</TableHeader>
<TableBody>
{data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED'
const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
return (
<TableRow
key={project.id}
@@ -894,6 +923,7 @@ export default function ProjectsPage() {
>
<ProjectLogo
project={project}
logoUrl={project.logoUrl}
size="sm"
fallback="initials"
/>
@@ -972,7 +1002,10 @@ export default function ProjectsPage() {
</div>
</TableCell>
<TableCell>
<StatusBadge status={project.status ?? 'SUBMITTED'} />
{(() => {
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
return <Badge variant={derived.variant}>{derived.label}</Badge>
})()}
</TableCell>
<TableCell className="relative z-10 text-right">
<DropdownMenu>
@@ -1042,13 +1075,16 @@ export default function ProjectsPage() {
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-8">
<ProjectLogo project={project} size="md" fallback="initials" />
<ProjectLogo project={project} logoUrl={project.logoUrl} size="md" fallback="initials" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
{project.title}
</CardTitle>
<StatusBadge status={project.status ?? 'SUBMITTED'} className="shrink-0" />
{(() => {
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
return <Badge variant={derived.variant} className="shrink-0">{derived.label}</Badge>
})()}
</div>
<CardDescription>{project.teamName}</CardDescription>
</div>
@@ -1096,7 +1132,7 @@ export default function ProjectsPage() {
/* Card View */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED'
const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
return (
<div key={project.id} className="relative">
<div className="absolute left-3 top-3 z-10">
@@ -1110,7 +1146,7 @@ export default function ProjectsPage() {
<Card className={`transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
<CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-7">
<ProjectLogo project={project} size="lg" fallback="initials" />
<ProjectLogo project={project} logoUrl={project.logoUrl} size="lg" fallback="initials" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
@@ -1177,7 +1213,10 @@ export default function ProjectsPage() {
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="flex items-center justify-between gap-2">
<StatusBadge status={project.status ?? 'SUBMITTED'} />
{(() => {
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
return <Badge variant={derived.variant}>{derived.label}</Badge>
})()}
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
@@ -1846,6 +1885,8 @@ export default function ProjectsPage() {
</div>
</DialogContent>
</Dialog>
<BulkNotificationDialog open={bulkNotifyOpen} onOpenChange={setBulkNotifyOpen} />
</div>
)
}

View File

@@ -3,6 +3,7 @@
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
@@ -20,6 +21,7 @@ import {
Video,
File,
Download,
Eye,
} from 'lucide-react'
const fileTypeIcons: Record<string, typeof FileText> = {
@@ -42,6 +44,34 @@ const fileTypeLabels: Record<string, string> = {
SUPPORTING_DOC: 'Supporting Document',
}
function FileActionButtons({ bucket, objectKey, fileName }: { bucket: string; objectKey: string; fileName: string }) {
const { data: viewData } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: false },
{ staleTime: 10 * 60 * 1000 }
)
const { data: dlData } = trpc.file.getDownloadUrl.useQuery(
{ bucket, objectKey, forDownload: true, fileName },
{ staleTime: 10 * 60 * 1000 }
)
const viewUrl = typeof viewData === 'string' ? viewData : viewData?.url
const dlUrl = typeof dlData === 'string' ? dlData : dlData?.url
return (
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" asChild disabled={!viewUrl}>
<a href={viewUrl || '#'} target="_blank" rel="noopener noreferrer">
<Eye className="h-3 w-3" /> View
</a>
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" asChild disabled={!dlUrl}>
<a href={dlUrl || '#'} download={fileName}>
<Download className="h-3 w-3" /> Download
</a>
</Button>
</div>
)
}
export default function ApplicantDocumentsPage() {
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
@@ -82,7 +112,7 @@ export default function ApplicantDocumentsPage() {
)
}
const { project, openRounds } = data
const { project, openRounds, isRejected } = data
const isDraft = !project.submittedAt
return (
@@ -98,8 +128,20 @@ export default function ApplicantDocumentsPage() {
</p>
</div>
{/* Rejected banner */}
{isRejected && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-4">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">
Your project was not selected to advance. Documents are view-only.
</p>
</CardContent>
</Card>
)}
{/* Per-round upload sections */}
{openRounds.length > 0 && (
{!isRejected && openRounds.length > 0 && (
<div className="space-y-6">
{openRounds.map((round: { id: string; name: string; windowCloseAt?: string | Date | null }) => {
const now = new Date()
@@ -163,18 +205,18 @@ export default function ApplicantDocumentsPage() {
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null; bucket?: string; objectKey?: string }
return (
<div
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-3 min-w-0">
<Icon className="h-5 w-5 text-muted-foreground shrink-0" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm">{file.fileName}</p>
<p className="font-medium text-sm truncate">{file.fileName}</p>
{fileRecord.isLate && (
<Badge variant="warning" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
@@ -189,6 +231,13 @@ export default function ApplicantDocumentsPage() {
</p>
</div>
</div>
{fileRecord.bucket && fileRecord.objectKey && (
<FileActionButtons
bucket={fileRecord.bucket}
objectKey={fileRecord.objectKey}
fileName={file.fileName}
/>
)}
</div>
)
})}

View File

@@ -111,7 +111,7 @@ export default function ApplicantDashboardPage() {
)
}
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data
const { project, timeline, currentStatus, openRounds, hasPassedIntake, isRejected } = data
const programYear = project.program?.year
const programName = project.program?.name
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
@@ -221,8 +221,23 @@ export default function ApplicantDashboardPage() {
</Card>
</AnimatedCard>
{/* Rejected banner */}
{isRejected && (
<AnimatedCard index={1}>
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-center gap-3 py-4">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<p className="text-sm text-destructive">
Your project was not selected to advance. Your project space is now read-only.
</p>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Quick actions */}
<AnimatedCard index={1}>
{!isRejected && (
<AnimatedCard index={2}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
@@ -266,6 +281,7 @@ export default function ApplicantDashboardPage() {
)}
</div>
</AnimatedCard>
)}
{/* Document Completeness */}
{docCompleteness && docCompleteness.length > 0 && (

View File

@@ -123,6 +123,7 @@ export default function ApplicantProjectPage() {
const project = dashboardData?.project
const projectId = project?.id
const isIntakeOpen = dashboardData?.isIntakeOpen ?? false
const isRejected = dashboardData?.isRejected ?? false
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
{ projectId: projectId! },
@@ -398,7 +399,7 @@ export default function ApplicantProjectPage() {
Everyone on this list can view and collaborate on this project.
</CardDescription>
</div>
{isTeamLead && (
{isTeamLead && !isRejected && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button size="sm">
@@ -578,7 +579,7 @@ export default function ApplicantProjectPage() {
</div>
</div>
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
{isTeamLead && !isRejected && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="text-destructive">

View File

@@ -19,13 +19,14 @@ export default async function AuthLayout({
// Redirect logged-in users to their dashboard
// But NOT if they still need to set their password
if (session?.user && !session.user.mustSetPassword) {
// Verify user still exists in DB (handles deleted accounts with stale sessions)
// Verify user still exists in DB and check onboarding status
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true },
select: { id: true, onboardingCompletedAt: true },
})
if (dbUser) {
const role = session.user.role
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
redirect('/admin')

View File

@@ -36,17 +36,9 @@ export default function SetPasswordPage() {
setIsSuccess(true)
// Update the session to reflect the password has been set
await updateSession()
// Redirect after a short delay
// Redirect after a short delay — all roles go to onboarding first
setTimeout(() => {
if (session?.user?.role === 'JURY_MEMBER') {
router.push('/jury')
} else if (session?.user?.role === 'SUPER_ADMIN' || session?.user?.role === 'PROGRAM_ADMIN') {
router.push('/admin')
} else if (session?.user?.role === 'APPLICANT') {
router.push('/onboarding')
} else {
router.push('/')
}
router.push('/onboarding')
}, 2000)
},
onError: (err) => {

View File

@@ -432,6 +432,7 @@ export function MembersContent() {
userEmail={user.email}
userStatus={user.status}
userRole={user.role as RoleValue}
userRoles={(user as unknown as { roles?: RoleValue[] }).roles}
currentUserRole={currentUserRole}
/>
</TableCell>
@@ -524,6 +525,7 @@ export function MembersContent() {
userEmail={user.email}
userStatus={user.status}
userRole={user.role as RoleValue}
userRoles={(user as unknown as { roles?: RoleValue[] }).roles}
currentUserRole={currentUserRole}
/>
</CardContent>

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

View File

@@ -4,6 +4,8 @@ import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Trophy } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyAdvancedButtonProps {
@@ -14,9 +16,10 @@ interface NotifyAdvancedButtonProps {
export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) {
const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>()
const [fullCustomBody, setFullCustomBody] = useState(false)
const preview = trpc.round.previewAdvancementEmail.useQuery(
{ roundId, targetRoundId, customMessage },
{ roundId, targetRoundId, customMessage, fullCustomBody },
{ enabled: open }
)
@@ -32,18 +35,31 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Advanced Teams</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send advancement emails to passed projects
</p>
<div className="space-y-2">
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
>
<Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Advanced Teams</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send advancement emails to passed projects
</p>
</div>
</button>
<div className="flex items-center gap-2 px-1">
<Switch
id="advancement-full-custom-body"
checked={fullCustomBody}
onCheckedChange={setFullCustomBody}
/>
<Label htmlFor="advancement-full-custom-body" className="text-xs cursor-pointer">
<span className="font-medium">Full custom body</span>
<span className="text-muted-foreground ml-1"> only your message is sent (no standard text)</span>
</Label>
</div>
</button>
</div>
<EmailPreviewDialog
open={open}
@@ -53,7 +69,7 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg })}
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg, fullCustomBody })}
isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)}
/>

View File

@@ -4,6 +4,8 @@ import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { XCircle } from 'lucide-react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { EmailPreviewDialog } from './email-preview-dialog'
interface NotifyRejectedButtonProps {
@@ -13,9 +15,10 @@ interface NotifyRejectedButtonProps {
export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
const [open, setOpen] = useState(false)
const [customMessage, setCustomMessage] = useState<string | undefined>()
const [fullCustomBody, setFullCustomBody] = useState(false)
const preview = trpc.round.previewRejectionEmail.useQuery(
{ roundId, customMessage },
{ roundId, customMessage, fullCustomBody },
{ enabled: open }
)
@@ -31,18 +34,31 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
return (
<>
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-red-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Non-Advanced</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send rejection emails to non-advanced projects
</p>
<div className="space-y-2">
<button
onClick={() => setOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-red-500 hover:-translate-y-0.5 hover:shadow-md transition-all text-left w-full"
>
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Notify Non-Advanced</p>
<p className="text-xs text-muted-foreground mt-0.5">
Send rejection emails to non-advanced projects
</p>
</div>
</button>
<div className="flex items-center gap-2 px-1">
<Switch
id="rejection-full-custom-body"
checked={fullCustomBody}
onCheckedChange={setFullCustomBody}
/>
<Label htmlFor="rejection-full-custom-body" className="text-xs cursor-pointer">
<span className="font-medium">Full custom body</span>
<span className="text-muted-foreground ml-1"> only your message is sent (no standard text)</span>
</Label>
</div>
</button>
</div>
<EmailPreviewDialog
open={open}
@@ -52,7 +68,7 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
recipientCount={preview.data?.recipientCount ?? 0}
previewHtml={preview.data?.html}
isPreviewLoading={preview.isLoading}
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg })}
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg, fullCustomBody })}
isSending={sendMutation.isPending}
onRefreshPreview={(msg) => setCustomMessage(msg)}
/>

View File

@@ -26,7 +26,7 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
}
const visConfig = (config.applicantVisibility as {
enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean
enabled?: boolean; showGlobalScore?: boolean; showCriterionScores?: boolean; showFeedbackText?: boolean; hideFromRejected?: boolean
}) ?? {}
const updateVisibility = (key: string, value: unknown) => {
@@ -293,6 +293,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="hideFromRejected">Hide from Rejected Applicants</Label>
<p className="text-xs text-muted-foreground">Applicants whose project was rejected will not see evaluations from this round</p>
</div>
<Switch
id="hideFromRejected"
checked={visConfig.hideFromRejected ?? false}
onCheckedChange={(v) => updateVisibility('hideFromRejected', v)}
/>
</div>
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
Evaluations are only visible to applicants after this round closes.
</p>

View File

@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuCheckboxItem,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
@@ -32,7 +33,6 @@ import {
Trash2,
Loader2,
Shield,
Check,
} from 'lucide-react'
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
@@ -50,10 +50,11 @@ interface UserActionsProps {
userEmail: string
userStatus: string
userRole: Role
userRoles?: Role[]
currentUserRole?: Role
}
export function UserActions({ userId, userEmail, userStatus, userRole, currentUserRole }: UserActionsProps) {
export function UserActions({ userId, userEmail, userStatus, userRole, userRoles, currentUserRole }: UserActionsProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isSending, setIsSending] = useState(false)
@@ -64,13 +65,13 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
utils.user.list.invalidate()
},
})
const updateUser = trpc.user.update.useMutation({
const updateRoles = trpc.user.updateRoles.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
toast.success('Roles updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
toast.error(error.message || 'Failed to update roles')
},
})
@@ -88,9 +89,20 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
// Can this user's role be changed by the current user?
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const handleRoleChange = (newRole: Role) => {
if (newRole === userRole) return
updateUser.mutate({ id: userId, role: newRole })
// Current roles for this user (array or fallback to single role)
const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole]
const handleToggleRole = (role: Role) => {
const has = currentRoles.includes(role)
let newRoles: Role[]
if (has) {
// Don't allow removing the last role
if (currentRoles.length <= 1) return
newRoles = currentRoles.filter(r => r !== role)
} else {
newRoles = [...currentRoles, role]
}
updateRoles.mutate({ userId, roles: newRoles })
}
const handleSendInvitation = async () => {
@@ -144,22 +156,20 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
</DropdownMenuItem>
{canChangeRole && (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={updateUser.isPending}>
<DropdownMenuSubTrigger disabled={updateRoles.isPending}>
<Shield className="mr-2 h-4 w-4" />
{updateUser.isPending ? 'Updating...' : 'Change Role'}
{updateRoles.isPending ? 'Updating...' : 'Roles'}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{getAvailableRoles().map((role) => (
<DropdownMenuItem
<DropdownMenuCheckboxItem
key={role}
onClick={() => handleRoleChange(role)}
disabled={role === userRole}
checked={currentRoles.includes(role)}
onCheckedChange={() => handleToggleRole(role)}
disabled={currentRoles.includes(role) && currentRoles.length <= 1}
>
{role === userRole && <Check className="mr-2 h-4 w-4" />}
<span className={role === userRole ? 'font-medium' : role !== userRole ? 'ml-6' : ''}>
{ROLE_LABELS[role]}
</span>
</DropdownMenuItem>
{ROLE_LABELS[role]}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
@@ -214,6 +224,7 @@ interface UserMobileActionsProps {
userEmail: string
userStatus: string
userRole: Role
userRoles?: Role[]
currentUserRole?: Role
}
@@ -222,23 +233,25 @@ export function UserMobileActions({
userEmail,
userStatus,
userRole,
userRoles,
currentUserRole,
}: UserMobileActionsProps) {
const [isSending, setIsSending] = useState(false)
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const updateUser = trpc.user.update.useMutation({
const updateRoles = trpc.user.updateRoles.useMutation({
onSuccess: () => {
utils.user.list.invalidate()
toast.success('Role updated successfully')
toast.success('Roles updated successfully')
},
onError: (error) => {
toast.error(error.message || 'Failed to update role')
toast.error(error.message || 'Failed to update roles')
},
})
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole]
const handleSendInvitation = async () => {
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
@@ -283,21 +296,31 @@ export function UserMobileActions({
</Button>
</div>
{canChangeRole && (
<select
value={userRole}
onChange={(e) => updateUser.mutate({ id: userId, role: e.target.value as Role })}
disabled={updateUser.isPending}
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
>
<div className="flex flex-wrap gap-1.5">
{(isSuperAdmin
? (['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
: (['JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
).map((role) => (
<option key={role} value={role}>
{ROLE_LABELS[role]}
</option>
))}
</select>
).map((role) => {
const isActive = currentRoles.includes(role)
return (
<Button
key={role}
variant={isActive ? 'default' : 'outline'}
size="sm"
className="h-6 text-xs px-2"
disabled={updateRoles.isPending || (isActive && currentRoles.length <= 1)}
onClick={() => {
const newRoles = isActive
? currentRoles.filter(r => r !== role)
: [...currentRoles, role]
updateRoles.mutate({ userId, roles: newRoles })
}}
>
{ROLE_LABELS[role]}
</Button>
)
})}
</div>
)}
</div>
)

View File

@@ -47,6 +47,7 @@ import {
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useDebouncedCallback } from 'use-debounce'
import { ProjectLogo } from '@/components/shared/project-logo'
export function ObserverProjectsContent() {
@@ -322,19 +323,29 @@ export function ObserverProjectsContent() {
className="cursor-pointer hover:bg-muted/50"
onClick={() => router.push(`/observer/projects/${project.id}`)}
>
<TableCell className="pl-6 max-w-[260px]">
<Link
href={`/observer/projects/${project.id}` as Route}
className="font-medium hover:underline truncate block"
onClick={(e) => e.stopPropagation()}
>
{project.title}
</Link>
{project.teamName && (
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
</p>
)}
<TableCell className="pl-6 max-w-[300px]">
<div className="flex items-center gap-3">
<ProjectLogo
project={project}
logoUrl={project.logoUrl}
size="sm"
fallback="initials"
/>
<div className="min-w-0">
<Link
href={`/observer/projects/${project.id}` as Route}
className="font-medium hover:underline truncate block"
onClick={(e) => e.stopPropagation()}
>
{project.title}
</Link>
{project.teamName && (
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
</div>
</TableCell>
<TableCell className="text-sm">
{project.country ?? '-'}
@@ -395,15 +406,23 @@ export function ObserverProjectsContent() {
<Card className="transition-colors hover:bg-muted/50">
<CardContent className="pt-4 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="font-medium text-sm leading-tight truncate">
{project.title}
</p>
{project.teamName && (
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
<div className="flex items-center gap-3 min-w-0">
<ProjectLogo
project={project}
logoUrl={project.logoUrl}
size="sm"
fallback="initials"
/>
<div className="min-w-0">
<p className="font-medium text-sm leading-tight truncate">
{project.title}
</p>
)}
{project.teamName && (
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
</div>
<StatusBadge status={project.observerStatus ?? project.status} />
</div>

View File

@@ -15,7 +15,19 @@ const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
adapter: PrismaAdapter(prisma),
adapter: {
...PrismaAdapter(prisma),
async useVerificationToken({ identifier, token }: { identifier: string; token: string }) {
try {
return await prisma.verificationToken.delete({
where: { identifier_token: { identifier, token } },
})
} catch (e) {
if ((e as { code?: string }).code === 'P2025') return null
throw e
}
},
},
providers: [
// Email provider for magic links (used for first login and password reset)
EmailProvider({
@@ -129,7 +141,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
},
})
if (!user || user.status === 'SUSPENDED' || !user.passwordHash) {
if (!user || user.status === 'SUSPENDED') {
// Track failed attempt (don't reveal whether user exists)
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
current.count++
@@ -139,19 +151,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
}
failedAttempts.set(email, current)
// Log failed login
// Log failed login — real security event
await prisma.auditLog.create({
data: {
userId: null,
action: 'LOGIN_FAILED',
entityType: 'User',
detailsJson: { email, reason: !user ? 'user_not_found' : user.status === 'SUSPENDED' ? 'suspended' : 'no_password' },
detailsJson: { email, reason: !user ? 'user_not_found' : 'suspended' },
},
}).catch(() => {})
return null
}
if (!user.passwordHash) {
// Magic-link user tried credentials form — expected, not a security event
return null
}
// Verify password
const isValid = await verifyPassword(password, user.passwordHash)
if (!isValid) {

View File

@@ -6,12 +6,20 @@ import { prisma } from '@/lib/prisma'
let cachedTransporter: Transporter | null = null
let cachedConfigHash = ''
let cachedFrom = ''
let cachedAt = 0
const CACHE_TTL = 60_000 // 1 minute
/**
* Get SMTP transporter using database settings with env var fallback.
* Caches the transporter and rebuilds it when settings change.
* Uses connection pooling for reliable bulk sends.
*/
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
const dbSettings = await prisma.systemSettings.findMany({
where: {
@@ -43,22 +51,42 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
// Check if config changed since last call
const configHash = `${host}:${port}:${user}:${pass}:${from}`
if (cachedTransporter && configHash === cachedConfigHash) {
cachedAt = Date.now()
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({
host,
port: parseInt(port),
secure: port === '465',
auth: { user, pass },
})
pool: true,
maxConnections: 5,
maxMessages: 10,
socketTimeout: 30_000,
connectionTimeout: 15_000,
} as nodemailer.TransportOptions)
cachedConfigHash = configHash
cachedFrom = from
cachedAt = Date.now()
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
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
@@ -1688,9 +1716,34 @@ export function getAdvancementNotificationTemplate(
toRoundName: string,
customMessage?: string,
accountUrl?: string,
fullCustomBody?: boolean,
): EmailTemplate {
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.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 = `
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
<tr>
@@ -1702,14 +1755,6 @@ export function getAdvancementNotificationTemplate(
</table>
`
const escapedMessage = customMessage
? customMessage
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
: null
const content = `
${sectionTitle(greeting)}
${celebrationBanner}
@@ -1757,7 +1802,8 @@ export function getRejectionNotificationTemplate(
name: string,
projectName: string,
roundName: string,
customMessage?: string
customMessage?: string,
fullCustomBody?: boolean,
): EmailTemplate {
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
@@ -1769,6 +1815,22 @@ export function getRejectionNotificationTemplate(
.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>
<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 = `
${sectionTitle(greeting)}
${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?.customMessage as string | undefined,
ctx.metadata?.accountUrl as string | undefined,
ctx.metadata?.fullCustomBody as boolean | undefined,
),
REJECTION_NOTIFICATION: (ctx) =>
getRejectionNotificationTemplate(
ctx.name || '',
(ctx.metadata?.projectName as string) || 'Your Project',
(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) =>

View File

@@ -126,6 +126,14 @@ export function getContentType(fileName: string): string {
* Validate image file type
*/
export function isValidImageType(contentType: string): boolean {
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const validTypes = [
'image/jpeg',
'image/jpg', // non-standard but common browser alias for image/jpeg
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'application/octet-stream', // some browsers send this as a fallback
]
return validTypes.includes(contentType)
}

View File

@@ -2,6 +2,7 @@ import { z } from 'zod'
import { router, observerProcedure } from '../trpc'
import { normalizeCountryToCode } from '@/lib/countries'
import { getUserAvatarUrl } from '../utils/avatar-url'
import { getProjectLogoUrl } from '../utils/project-logo-url'
import { aggregateVotes } from '../services/deliberation'
const editionOrRoundInput = z.object({
@@ -1020,6 +1021,8 @@ export const analyticsRouter = router({
teamName: true,
status: true,
country: true,
logoKey: true,
logoProvider: true,
assignments: {
select: {
roundId: true,
@@ -1048,7 +1051,7 @@ export const analyticsRouter = router({
ctx.prisma.project.count({ where }),
])
const mapped = projects.map((p) => {
const mapped = await Promise.all(projects.map(async (p) => {
const submitted = p.assignments
.map((a) => a.evaluation)
.filter((e) => e?.status === 'SUBMITTED')
@@ -1080,6 +1083,8 @@ export const analyticsRouter = router({
else if (drafts.length > 0) observerStatus = 'UNDER_REVIEW'
else observerStatus = 'NOT_REVIEWED'
const logoUrl = await getProjectLogoUrl(p.logoKey, p.logoProvider)
return {
id: p.id,
title: p.title,
@@ -1087,12 +1092,13 @@ export const analyticsRouter = router({
status: p.status,
observerStatus,
country: p.country,
logoUrl,
roundId: furthestRoundState?.round?.id ?? roundAssignment?.round?.id ?? '',
roundName: furthestRoundState?.round?.name ?? roundAssignment?.round?.name ?? '',
averageScore,
evaluationCount: submitted.length,
}
})
}))
// Filter by observer-derived status in JS
const observerStatusFilter = input.status && OBSERVER_DERIVED_STATUSES.includes(input.status)

View File

@@ -20,6 +20,16 @@ function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex')
}
/** Check if a project has been rejected in any round (based on ProjectRoundState, not Project.status) */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function isProjectRejected(prisma: any, projectId: string): Promise<boolean> {
const rejected = await prisma.projectRoundState.findFirst({
where: { projectId, state: 'REJECTED' },
select: { id: true },
})
return !!rejected
}
export const applicantRouter = router({
/**
* Get submission info for an applicant (by round slug)
@@ -276,6 +286,11 @@ export const applicantRouter = router({
})
}
// Block rejected projects from uploading
if (await isProjectRejected(ctx.prisma, input.projectId)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Uploads are no longer permitted.' })
}
// If uploading against a requirement, validate mime type and size
if (input.requirementId) {
const requirement = await ctx.prisma.fileRequirement.findUnique({
@@ -303,21 +318,29 @@ export const applicantRouter = router({
let isLate = false
// Can't upload if already submitted
if (project.submittedAt && !isLate) {
// Can't upload if already submitted — but only for initial application edits.
// Round-specific uploads (business plan, video for later rounds) are allowed
// as long as the round is active.
if (project.submittedAt && !input.roundId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project',
})
}
// Fetch round name for storage path (if uploading against a round)
// Fetch round info and verify it's active
let roundName: string | undefined
if (input.roundId) {
const round = await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true },
select: { name: true, status: true },
})
if (round && round.status !== 'ROUND_ACTIVE') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This round is closed. Documents can no longer be uploaded.',
})
}
roundName = round?.name
}
@@ -385,6 +408,11 @@ export const applicantRouter = router({
})
}
// Block rejected projects
if (await isProjectRejected(ctx.prisma, input.projectId)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. File changes are no longer permitted.' })
}
const { projectId, roundId, isLate, requirementId, ...fileData } = input
// Delete existing file: by requirementId if provided, otherwise by fileType
@@ -459,14 +487,33 @@ export const applicantRouter = router({
})
}
// Can't delete if project is submitted
if (file.project.submittedAt) {
// Block rejected projects
if (await isProjectRejected(ctx.prisma, file.project.id)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. File changes are no longer permitted.' })
}
// Can't delete initial application files after submission
if (file.project.submittedAt && !file.roundId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project',
})
}
// Round-specific files can only be deleted while the round is active
if (file.roundId) {
const round = await ctx.prisma.round.findUnique({
where: { id: file.roundId },
select: { status: true },
})
if (round && round.status !== 'ROUND_ACTIVE') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'This round is closed. Documents can no longer be modified.',
})
}
}
await ctx.prisma.projectFile.delete({
where: { id: input.fileId },
})
@@ -809,6 +856,11 @@ export const applicantRouter = router({
})
}
// Block rejected projects
if (await isProjectRejected(ctx.prisma, input.projectId)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Team changes are no longer permitted.' })
}
// Check if already a team member
const existingMember = await ctx.prisma.teamMember.findFirst({
where: {
@@ -1020,6 +1072,11 @@ export const applicantRouter = router({
})
}
// Block rejected projects
if (await isProjectRejected(ctx.prisma, input.projectId)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Team changes are no longer permitted.' })
}
// Can't remove the original submitter
if (project.submittedByUserId === input.userId) {
throw new TRPCError({
@@ -1234,7 +1291,7 @@ export const applicantRouter = router({
}
}
const isRejected = currentStatus === 'REJECTED'
const isRejected = await isProjectRejected(ctx.prisma, project.id)
const hasWonAward = project.wonAwards.length > 0
// Build timeline
@@ -1382,9 +1439,10 @@ export const applicantRouter = router({
isTeamLead,
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
},
openRounds,
openRounds: isRejected ? [] : openRounds,
timeline,
currentStatus,
isRejected,
hasPassedIntake: !!passedIntake,
isIntakeOpen: !!activeIntakeRound,
logoUrl,
@@ -1442,9 +1500,12 @@ export const applicantRouter = router({
select: { configJson: true },
})
: []
const navProjectRejected = await isProjectRejected(ctx.prisma, project.id)
hasEvaluationRounds = closedEvalRounds.some((r) => {
const parsed = EvaluationConfigSchema.safeParse(r.configJson)
return parsed.success && parsed.data.applicantVisibility.enabled
if (!parsed.success || !parsed.data.applicantVisibility.enabled) return false
if (parsed.data.applicantVisibility.hideFromRejected && navProjectRejected) return false
return true
})
}
@@ -1746,11 +1807,16 @@ export const applicantRouter = router({
}>
}> = []
const projectIsRejected = await isProjectRejected(ctx.prisma, project.id)
for (let i = 0; i < evalRounds.length; i++) {
const round = evalRounds[i]
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
// Skip this round if hideFromRejected is on and the project has been rejected
if (parsed.data.applicantVisibility.hideFromRejected && projectIsRejected) continue
const vis = parsed.data.applicantVisibility
// Get evaluations via assignments — NEVER select userId or user relation
@@ -1991,6 +2057,11 @@ export const applicantRouter = router({
throw new TRPCError({ code: 'FORBIDDEN', message: 'You are not a member of this project' })
}
// Block rejected projects
if (await isProjectRejected(ctx.prisma, input.projectId)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Your project has been rejected. Logo changes are no longer permitted.' })
}
return getImageUploadUrl(
input.projectId,
input.fileName,

View File

@@ -4,13 +4,17 @@ import { TRPCError } from '@trpc/server'
import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc'
import { getUserAvatarUrl } from '../utils/avatar-url'
import { attachProjectLogoUrls } from '../utils/project-logo-url'
import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
import { sendInvitationEmail } from '@/lib/email'
import { sendInvitationEmail, getBaseUrl } from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '../utils/invite'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
const STATUSES_WITH_TEAM_NOTIFICATIONS = ['SEMIFINALIST', 'FINALIST', 'REJECTED'] as const
@@ -140,7 +144,7 @@ export const projectRouter = router({
}
}
const [projects, total, statusGroups] = await Promise.all([
const [projects, total, roundStateCounts] = await Promise.all([
ctx.prisma.project.findMany({
where,
skip,
@@ -149,24 +153,33 @@ export const projectRouter = router({
include: {
program: { select: { id: true, name: true, year: true } },
_count: { select: { assignments: true, files: true } },
projectRoundStates: {
select: {
state: true,
round: { select: { name: true, sortOrder: true } },
},
orderBy: { round: { sortOrder: 'desc' } },
},
},
}),
ctx.prisma.project.count({ where }),
ctx.prisma.project.groupBy({
by: ['status'],
where,
ctx.prisma.projectRoundState.groupBy({
by: ['state'],
where: where.programId ? { project: { programId: where.programId as string } } : {},
_count: true,
}),
])
// Build status counts from groupBy (across all pages)
// Build round-state counts
const statusCounts: Record<string, number> = {}
for (const g of statusGroups) {
statusCounts[g.status] = g._count
for (const g of roundStateCounts) {
statusCounts[g.state] = g._count
}
const projectsWithLogos = await attachProjectLogoUrls(projects)
return {
projects,
projects: projectsWithLogos,
total,
page,
perPage,
@@ -1189,6 +1202,13 @@ export const projectRouter = router({
},
},
},
projectRoundStates: {
select: {
state: true,
round: { select: { name: true, sortOrder: true } },
},
orderBy: { round: { sortOrder: 'desc' } },
},
},
}),
ctx.prisma.projectTag.findMany({
@@ -1389,4 +1409,761 @@ export const projectRouter = router({
return project
}),
/**
* Add a team member to a project (admin only).
* Finds or creates user, then creates TeamMember record.
* Optionally sends invite email if user has no password set.
*/
addTeamMember: adminProcedure
.input(
z.object({
projectId: z.string(),
email: z.string().email(),
name: z.string().min(1),
role: z.enum(['LEAD', 'MEMBER', 'ADVISOR']),
title: z.string().optional(),
sendInvite: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
const { projectId, email, name, role, title, sendInvite } = input
// Verify project exists
await ctx.prisma.project.findUniqueOrThrow({
where: { id: projectId },
select: { id: true },
})
// Find or create user
let user = await ctx.prisma.user.findUnique({
where: { email: email.toLowerCase() },
select: { id: true, name: true, email: true, passwordHash: true, status: true },
})
if (!user) {
user = await ctx.prisma.user.create({
data: {
email: email.toLowerCase(),
name,
role: 'APPLICANT',
roles: ['APPLICANT'],
status: 'INVITED',
},
select: { id: true, name: true, email: true, passwordHash: true, status: true },
})
}
// Create TeamMember record
let teamMember
try {
teamMember = await ctx.prisma.teamMember.create({
data: {
projectId,
userId: user.id,
role,
title: title || null,
},
include: {
user: {
select: { id: true, name: true, email: true },
},
},
})
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw new TRPCError({
code: 'CONFLICT',
message: 'This user is already a team member of this project',
})
}
throw err
}
// Send invite email if requested and user has no password
if (sendInvite && !user.passwordHash) {
try {
const token = generateInviteToken()
const expiryMs = await getInviteExpiryMs(ctx.prisma)
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
},
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://portal.monaco-opc.com'
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(email.toLowerCase(), name, inviteUrl, 'APPLICANT')
} catch {
// Email sending failure should not block member creation
console.error(`Failed to send invite to ${email}`)
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ADD_TEAM_MEMBER',
entityType: 'Project',
entityId: projectId,
detailsJson: { memberId: user.id, email, role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return teamMember
}),
/**
* Remove a team member from a project (admin only).
* Prevents removing the last LEAD.
*/
removeTeamMember: adminProcedure
.input(
z.object({
projectId: z.string(),
userId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const { projectId, userId } = input
// Check if this is the last LEAD
const targetMember = await ctx.prisma.teamMember.findUniqueOrThrow({
where: { projectId_userId: { projectId, userId } },
select: { id: true, role: true },
})
if (targetMember.role === 'LEAD') {
const leadCount = await ctx.prisma.teamMember.count({
where: { projectId, role: 'LEAD' },
})
if (leadCount <= 1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot remove the last team lead',
})
}
}
await ctx.prisma.teamMember.delete({
where: { projectId_userId: { projectId, userId } },
})
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REMOVE_TEAM_MEMBER',
entityType: 'Project',
entityId: projectId,
detailsJson: { removedUserId: userId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { success: true }
}),
// =========================================================================
// BULK NOTIFICATION ENDPOINTS
// =========================================================================
/**
* Get summary of projects eligible for bulk notifications.
* Returns counts for passed (by round), rejected, and award pool projects,
* plus how many have already been notified.
*/
getBulkNotificationSummary: adminProcedure
.query(async ({ ctx }) => {
// 1. Passed projects grouped by round
const passedStates = await ctx.prisma.projectRoundState.findMany({
where: { state: 'PASSED' },
select: {
projectId: true,
roundId: true,
round: { select: { name: true, sortOrder: true, competition: { select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } } } } },
},
})
// Group by round and compute next round name
const passedByRound = new Map<string, { roundId: string; roundName: string; nextRoundName: string; projectIds: Set<string> }>()
for (const ps of passedStates) {
if (!passedByRound.has(ps.roundId)) {
const rounds = ps.round.competition.rounds
const idx = rounds.findIndex((r) => r.id === ps.roundId)
const nextRound = rounds[idx + 1]
passedByRound.set(ps.roundId, {
roundId: ps.roundId,
roundName: ps.round.name,
nextRoundName: nextRound?.name ?? 'Next Round',
projectIds: new Set(),
})
}
passedByRound.get(ps.roundId)!.projectIds.add(ps.projectId)
}
const passed = [...passedByRound.values()].map((g) => ({
roundId: g.roundId,
roundName: g.roundName,
nextRoundName: g.nextRoundName,
projectCount: g.projectIds.size,
}))
// 2. Rejected projects (REJECTED in ProjectRoundState + FILTERED_OUT in FilteringResult)
const [rejectedPRS, filteredOut] = await Promise.all([
ctx.prisma.projectRoundState.findMany({
where: { state: 'REJECTED' },
select: { projectId: true },
}),
ctx.prisma.filteringResult.findMany({
where: {
OR: [
{ finalOutcome: 'FILTERED_OUT' },
{ outcome: 'FILTERED_OUT', finalOutcome: null },
],
},
select: { projectId: true },
}),
])
const rejectedProjectIds = new Set([
...rejectedPRS.map((r) => r.projectId),
...filteredOut.map((r) => r.projectId),
])
// 3. Award pools
const awards = await ctx.prisma.specialAward.findMany({
select: {
id: true,
name: true,
_count: { select: { eligibilities: { where: { eligible: true } } } },
},
})
const awardPools = awards.map((a) => ({
awardId: a.id,
awardName: a.name,
eligibleCount: a._count.eligibilities,
}))
// 4. Already-sent counts from NotificationLog
const [advancementSent, rejectionSent] = await Promise.all([
ctx.prisma.notificationLog.count({
where: { type: 'ADVANCEMENT_NOTIFICATION', status: 'SENT' },
}),
ctx.prisma.notificationLog.count({
where: { type: 'REJECTION_NOTIFICATION', status: 'SENT' },
}),
])
return {
passed,
rejected: { count: rejectedProjectIds.size },
awardPools,
alreadyNotified: { advancement: advancementSent, rejection: rejectionSent },
}
}),
/**
* Send bulk advancement notifications to all PASSED projects.
* Groups by round, determines next round, sends via batch sender.
* Skips projects that have already been notified (unless skipAlreadySent=false).
*/
sendBulkPassedNotifications: adminProcedure
.input(
z.object({
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
skipAlreadySent: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
const { customMessage, fullCustomBody, skipAlreadySent } = input
// Find all PASSED project round states
const passedStates = await ctx.prisma.projectRoundState.findMany({
where: { state: 'PASSED' },
select: {
projectId: true,
roundId: true,
round: {
select: {
name: true,
sortOrder: true,
competition: {
select: {
rounds: {
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
},
},
},
},
},
},
})
// Get already-sent project IDs if needed
const alreadySentProjectIds = new Set<string>()
if (skipAlreadySent) {
const sentLogs = await ctx.prisma.notificationLog.findMany({
where: { type: 'ADVANCEMENT_NOTIFICATION', status: 'SENT', projectId: { not: null } },
select: { projectId: true },
distinct: ['projectId'],
})
for (const log of sentLogs) {
if (log.projectId) alreadySentProjectIds.add(log.projectId)
}
}
// Group by round for next-round resolution
const roundMap = new Map<string, { roundName: string; nextRoundName: string }>()
const projectIds = new Set<string>()
for (const ps of passedStates) {
if (skipAlreadySent && alreadySentProjectIds.has(ps.projectId)) continue
projectIds.add(ps.projectId)
if (!roundMap.has(ps.roundId)) {
const rounds = ps.round.competition.rounds
const idx = rounds.findIndex((r) => r.id === ps.roundId)
const nextRound = rounds[idx + 1]
roundMap.set(ps.roundId, {
roundName: ps.round.name,
nextRoundName: nextRound?.name ?? 'Next Round',
})
}
}
if (projectIds.size === 0) {
return { sent: 0, failed: 0, skipped: alreadySentProjectIds.size }
}
// Fetch projects with team members
const projects = await ctx.prisma.project.findMany({
where: { id: { in: [...projectIds] } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
},
projectRoundStates: {
where: { state: 'PASSED' },
select: { roundId: true },
take: 1,
},
},
})
// For passwordless users: generate invite tokens
const baseUrl = getBaseUrl()
const passwordlessUserIds: string[] = []
for (const project of projects) {
for (const tm of project.teamMembers) {
if (!tm.user.passwordHash) {
passwordlessUserIds.push(tm.user.id)
}
}
}
const tokenMap = new Map<string, string>()
if (passwordlessUserIds.length > 0) {
const expiryMs = await getInviteExpiryMs(ctx.prisma)
for (const userId of [...new Set(passwordlessUserIds)]) {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: userId },
data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs) },
})
tokenMap.set(userId, token)
}
}
// Build notification items
const items: NotificationItem[] = []
for (const project of projects) {
const roundId = project.projectRoundStates[0]?.roundId
const roundInfo = roundId ? roundMap.get(roundId) : undefined
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, { name, userId }] of recipients) {
const inviteToken = tokenMap.get(userId)
const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined
items.push({
email,
name: name || '',
type: 'ADVANCEMENT_NOTIFICATION',
context: {
title: 'Your project has advanced!',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
fromRoundName: roundInfo?.roundName ?? 'this round',
toRoundName: roundInfo?.nextRoundName ?? 'Next Round',
customMessage: customMessage || undefined,
fullCustomBody,
accountUrl,
},
},
projectId: project.id,
userId: userId || undefined,
roundId: roundId || undefined,
})
}
}
const result = await sendBatchNotifications(items)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_BULK_PASSED_NOTIFICATIONS',
entityType: 'Project',
entityId: 'bulk',
detailsJson: {
sent: result.sent,
failed: result.failed,
projectCount: projectIds.size,
skipped: alreadySentProjectIds.size,
batchId: result.batchId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent: result.sent, failed: result.failed, skipped: alreadySentProjectIds.size }
}),
/**
* Send bulk rejection notifications to all REJECTED and FILTERED_OUT projects.
* Deduplicates by project, uses highest-sortOrder rejection round as context.
*/
sendBulkRejectionNotifications: adminProcedure
.input(
z.object({
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
includeInviteLink: z.boolean().default(false),
skipAlreadySent: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
const { customMessage, fullCustomBody, includeInviteLink, skipAlreadySent } = input
// Find REJECTED from ProjectRoundState
const rejectedPRS = await ctx.prisma.projectRoundState.findMany({
where: { state: 'REJECTED' },
select: {
projectId: true,
roundId: true,
round: { select: { name: true, sortOrder: true } },
},
})
// Find FILTERED_OUT from FilteringResult
const filteredOut = await ctx.prisma.filteringResult.findMany({
where: {
OR: [
{ finalOutcome: 'FILTERED_OUT' },
{ outcome: 'FILTERED_OUT', finalOutcome: null },
],
},
select: {
projectId: true,
roundId: true,
round: { select: { name: true, sortOrder: true } },
},
})
// Deduplicate by project, keep highest-sortOrder rejection round
const projectRejectionMap = new Map<string, { roundId: string; roundName: string; sortOrder: number }>()
for (const r of [...rejectedPRS, ...filteredOut]) {
const existing = projectRejectionMap.get(r.projectId)
if (!existing || r.round.sortOrder > existing.sortOrder) {
projectRejectionMap.set(r.projectId, {
roundId: r.roundId,
roundName: r.round.name,
sortOrder: r.round.sortOrder,
})
}
}
// Skip already-sent
const alreadySentProjectIds = new Set<string>()
if (skipAlreadySent) {
const sentLogs = await ctx.prisma.notificationLog.findMany({
where: { type: 'REJECTION_NOTIFICATION', status: 'SENT', projectId: { not: null } },
select: { projectId: true },
distinct: ['projectId'],
})
for (const log of sentLogs) {
if (log.projectId) alreadySentProjectIds.add(log.projectId)
}
}
const targetProjectIds = [...projectRejectionMap.keys()].filter(
(pid) => !skipAlreadySent || !alreadySentProjectIds.has(pid)
)
if (targetProjectIds.length === 0) {
return { sent: 0, failed: 0, skipped: alreadySentProjectIds.size }
}
// Fetch projects with team members
const projects = await ctx.prisma.project.findMany({
where: { id: { in: targetProjectIds } },
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
},
},
})
// Generate invite tokens for passwordless users if needed
const baseUrl = getBaseUrl()
const tokenMap = new Map<string, string>()
if (includeInviteLink) {
const passwordlessUserIds = new Set<string>()
for (const project of projects) {
for (const tm of project.teamMembers) {
if (!tm.user.passwordHash) passwordlessUserIds.add(tm.user.id)
}
}
if (passwordlessUserIds.size > 0) {
const expiryMs = await getInviteExpiryMs(ctx.prisma)
for (const userId of passwordlessUserIds) {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: userId },
data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs) },
})
tokenMap.set(userId, token)
}
}
}
// Build notification items
const items: NotificationItem[] = []
for (const project of projects) {
const rejection = projectRejectionMap.get(project.id)
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, { name, userId }] of recipients) {
const inviteToken = tokenMap.get(userId)
const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined
items.push({
email,
name: name || '',
type: 'REJECTION_NOTIFICATION',
context: {
title: 'Project Status Update',
message: '',
linkUrl: includeInviteLink ? accountUrl : undefined,
metadata: {
projectName: project.title,
roundName: rejection?.roundName ?? 'this round',
customMessage: customMessage || undefined,
fullCustomBody,
},
},
projectId: project.id,
userId: userId || undefined,
roundId: rejection?.roundId,
})
}
}
const result = await sendBatchNotifications(items)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_BULK_REJECTION_NOTIFICATIONS',
entityType: 'Project',
entityId: 'bulk',
detailsJson: {
sent: result.sent,
failed: result.failed,
projectCount: targetProjectIds.length,
skipped: alreadySentProjectIds.size,
batchId: result.batchId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent: result.sent, failed: result.failed, skipped: alreadySentProjectIds.size }
}),
/**
* Send bulk award pool notifications for a specific award.
* Uses the existing award notification pattern via batch sender.
*/
sendBulkAwardNotifications: adminProcedure
.input(
z.object({
awardId: z.string(),
customMessage: z.string().optional(),
skipAlreadySent: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
const { awardId, customMessage, skipAlreadySent } = input
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: awardId },
select: { id: true, name: true },
})
// Get eligible projects for this award
const eligibilities = await ctx.prisma.awardEligibility.findMany({
where: {
awardId,
eligible: true,
...(skipAlreadySent ? { notifiedAt: null } : {}),
},
select: {
id: true,
projectId: true,
project: {
select: {
id: true,
title: true,
submittedByEmail: true,
teamMembers: {
select: { user: { select: { id: true, email: true, name: true, passwordHash: true } } },
},
},
},
},
})
if (eligibilities.length === 0) {
return { sent: 0, failed: 0, skipped: 0 }
}
// Generate invite tokens for passwordless users
const baseUrl = getBaseUrl()
const tokenMap = new Map<string, string>()
const passwordlessUserIds = new Set<string>()
for (const elig of eligibilities) {
for (const tm of elig.project.teamMembers) {
if (!tm.user.passwordHash) passwordlessUserIds.add(tm.user.id)
}
}
if (passwordlessUserIds.size > 0) {
const expiryMs = await getInviteExpiryMs(ctx.prisma)
for (const userId of passwordlessUserIds) {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: userId },
data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs) },
})
tokenMap.set(userId, token)
}
}
// Build items with eligibility tracking
const eligibilityEmailMap = new Map<string, Set<string>>() // eligId -> emails
const items: NotificationItem[] = []
for (const elig of eligibilities) {
const project = elig.project
const emailsForElig = new Set<string>()
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, { name, userId }] of recipients) {
emailsForElig.add(email)
const inviteToken = tokenMap.get(userId)
const accountUrl = inviteToken ? `${baseUrl}/accept-invite?token=${inviteToken}` : undefined
items.push({
email,
name: name || '',
type: 'AWARD_SELECTION_NOTIFICATION',
context: {
title: `Your project is being considered for ${award.name}`,
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
awardName: award.name,
customMessage: customMessage || undefined,
accountUrl,
},
},
projectId: project.id,
userId: userId || undefined,
})
}
eligibilityEmailMap.set(elig.id, emailsForElig)
}
const result = await sendBatchNotifications(items)
// Stamp notifiedAt only for eligibilities where all emails succeeded
const failedEmails = new Set(result.errors.map((e) => e.email))
for (const [eligId, emails] of eligibilityEmailMap) {
const anyFailed = [...emails].some((e) => failedEmails.has(e))
if (!anyFailed) {
await ctx.prisma.awardEligibility.update({
where: { id: eligId },
data: { notifiedAt: new Date() },
})
}
}
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_BULK_AWARD_NOTIFICATIONS',
entityType: 'SpecialAward',
entityId: awardId,
detailsJson: {
awardName: award.name,
sent: result.sent,
failed: result.failed,
eligibilityCount: eligibilities.length,
batchId: result.batchId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent: result.sent, failed: result.failed, skipped: 0 }
}),
})

View File

@@ -9,10 +9,11 @@ import { createBulkNotifications } from '../services/in-app-notification'
import {
getAdvancementNotificationTemplate,
getRejectionNotificationTemplate,
sendStyledNotificationEmail,
sendInvitationEmail,
getBaseUrl,
} from '@/lib/email'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
import { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
import {
openWindow,
@@ -812,10 +813,11 @@ export const roundRouter = router({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
})
)
.query(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const { roundId, targetRoundId, customMessage, fullCustomBody } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
@@ -865,7 +867,9 @@ export const roundRouter = router({
'Your Project',
currentRound.name,
toRoundName,
customMessage || undefined
customMessage || undefined,
undefined,
fullCustomBody,
)
return { html: template.html, subject: template.subject, recipientCount }
@@ -877,11 +881,12 @@ export const roundRouter = router({
roundId: z.string(),
targetRoundId: z.string().optional(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
projectIds: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, targetRoundId, customMessage } = input
const { roundId, targetRoundId, customMessage, fullCustomBody } = input
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
@@ -922,48 +927,47 @@ export const roundRouter = router({
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
const items: NotificationItem[] = []
for (const project of projects) {
const recipients = new Map<string, string | null>()
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, name] of recipients) {
try {
await sendStyledNotificationEmail(
email,
name || '',
'ADVANCEMENT_NOTIFICATION',
{
title: 'Your project has advanced!',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
fromRoundName: currentRound.name,
toRoundName,
customMessage: customMessage || undefined,
},
}
)
sent++
} catch (err) {
console.error(`[sendAdvancementNotifications] Failed for ${email}:`, err)
failed++
}
for (const [email, { name, userId }] of recipients) {
items.push({
email,
name: name || '',
type: 'ADVANCEMENT_NOTIFICATION',
context: {
title: 'Your project has advanced!',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
fromRoundName: currentRound.name,
toRoundName,
customMessage: customMessage || undefined,
fullCustomBody,
},
},
projectId: project.id,
userId: userId || undefined,
roundId,
})
}
}
const result = await sendBatchNotifications(items)
// Create in-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
@@ -985,12 +989,12 @@ export const roundRouter = router({
action: 'SEND_ADVANCEMENT_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
detailsJson: { sent: result.sent, failed: result.failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
return { sent: result.sent, failed: result.failed }
}),
previewRejectionEmail: adminProcedure
@@ -998,22 +1002,36 @@ export const roundRouter = router({
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
})
)
.query(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const { roundId, customMessage, fullCustomBody } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true },
select: { name: true, roundType: true },
})
// Count recipients: team members of REJECTED projects
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
// For FILTERING rounds, also count projects filtered out via FilteringResult
let projectIds: string[]
if (round.roundType === 'FILTERING') {
const fromPRS = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const fromFR = await ctx.prisma.filteringResult.findMany({
where: { roundId, finalOutcome: 'FILTERED_OUT' },
select: { projectId: true },
})
projectIds = [...new Set([...fromPRS, ...fromFR].map((p) => p.projectId))]
} else {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
projectIds = projectStates.map((ps) => ps.projectId)
}
let recipientCount = 0
if (projectIds.length > 0) {
@@ -1039,7 +1057,8 @@ export const roundRouter = router({
'Team Member',
'Your Project',
round.name,
customMessage || undefined
customMessage || undefined,
fullCustomBody,
)
return { html: template.html, subject: template.subject, recipientCount }
@@ -1050,21 +1069,36 @@ export const roundRouter = router({
z.object({
roundId: z.string(),
customMessage: z.string().optional(),
fullCustomBody: z.boolean().default(false),
})
)
.mutation(async ({ ctx, input }) => {
const { roundId, customMessage } = input
const { roundId, customMessage, fullCustomBody } = input
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: roundId },
select: { name: true },
select: { name: true, roundType: true },
})
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const projectIds = projectStates.map((ps) => ps.projectId)
// For FILTERING rounds, also include projects filtered out via FilteringResult
let projectIds: string[]
if (round.roundType === 'FILTERING') {
const fromPRS = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
const fromFR = await ctx.prisma.filteringResult.findMany({
where: { roundId, finalOutcome: 'FILTERED_OUT' },
select: { projectId: true },
})
projectIds = [...new Set([...fromPRS, ...fromFR].map((p) => p.projectId))]
} else {
const projectStates = await ctx.prisma.projectRoundState.findMany({
where: { roundId, state: 'REJECTED' },
select: { projectId: true },
})
projectIds = projectStates.map((ps) => ps.projectId)
}
if (projectIds.length === 0) {
return { sent: 0, failed: 0 }
@@ -1082,47 +1116,46 @@ export const roundRouter = router({
},
})
let sent = 0
let failed = 0
const allUserIds = new Set<string>()
const items: NotificationItem[] = []
for (const project of projects) {
const recipients = new Map<string, string | null>()
const recipients = new Map<string, { name: string | null; userId: string }>()
for (const tm of project.teamMembers) {
if (tm.user.email) {
recipients.set(tm.user.email, tm.user.name)
recipients.set(tm.user.email, { name: tm.user.name, userId: tm.user.id })
allUserIds.add(tm.user.id)
}
}
if (recipients.size === 0 && project.submittedByEmail) {
recipients.set(project.submittedByEmail, null)
recipients.set(project.submittedByEmail, { name: null, userId: '' })
}
for (const [email, name] of recipients) {
try {
await sendStyledNotificationEmail(
email,
name || '',
'REJECTION_NOTIFICATION',
{
title: 'Update on your application',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
roundName: round.name,
customMessage: customMessage || undefined,
},
}
)
sent++
} catch (err) {
console.error(`[sendRejectionNotifications] Failed for ${email}:`, err)
failed++
}
for (const [email, { name, userId }] of recipients) {
items.push({
email,
name: name || '',
type: 'REJECTION_NOTIFICATION',
context: {
title: 'Update on your application',
message: '',
linkUrl: '/applicant',
metadata: {
projectName: project.title,
roundName: round.name,
customMessage: customMessage || undefined,
fullCustomBody,
},
},
projectId: project.id,
userId: userId || undefined,
roundId,
})
}
}
const result = await sendBatchNotifications(items)
// In-app notifications
if (allUserIds.size > 0) {
void createBulkNotifications({
@@ -1142,12 +1175,12 @@ export const roundRouter = router({
action: 'SEND_REJECTION_NOTIFICATIONS',
entityType: 'Round',
entityId: roundId,
detailsJson: { sent, failed, projectCount: projectIds.length, customMessage: !!customMessage },
detailsJson: { sent: result.sent, failed: result.failed, projectCount: projectIds.length, customMessage: !!customMessage },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { sent, failed }
return { sent: result.sent, failed: result.failed }
}),
getBulkInvitePreview: adminProcedure

View File

@@ -4,8 +4,10 @@ import { Prisma } from '@prisma/client'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { processEligibilityJob } from '../services/award-eligibility-job'
import { sendStyledNotificationEmail, getAwardSelectionNotificationTemplate } from '@/lib/email'
import { getAwardSelectionNotificationTemplate } from '@/lib/email'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import { sendBatchNotifications } from '../services/notification-sender'
import type { NotificationItem } from '../services/notification-sender'
import type { PrismaClient } from '@prisma/client'
/**
@@ -1270,8 +1272,18 @@ export const specialAwardRouter = router({
})
// Get eligible projects that haven't been notified yet
// Exclude projects that have been rejected at any stage
const eligibilities = await ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true, notifiedAt: null },
where: {
awardId: input.awardId,
eligible: true,
notifiedAt: null,
project: {
projectRoundStates: {
none: { state: 'REJECTED' },
},
},
},
select: {
id: true,
projectId: true,
@@ -1324,12 +1336,12 @@ export const specialAwardRouter = router({
})
}
// Send emails
let emailsSent = 0
let emailsFailed = 0
// Build notification items — track which eligibility each email belongs to
const items: NotificationItem[] = []
const eligibilityEmailMap = new Map<string, Set<string>>() // eligibilityId → Set<email>
for (const e of eligibilities) {
const recipients: Array<{ id: string; email: string; name: string | null; passwordHash: string | null }> = []
const recipients: Array<{ id: string; email: string; name: string | null }> = []
if (e.project.submittedBy) recipients.push(e.project.submittedBy)
for (const tm of e.project.teamMembers) {
if (!recipients.some((r) => r.id === tm.user.id)) {
@@ -1337,39 +1349,46 @@ export const specialAwardRouter = router({
}
}
const emails = new Set<string>()
for (const recipient of recipients) {
const token = tokenMap.get(recipient.id)
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
emails.add(recipient.email)
try {
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'AWARD_SELECTION_NOTIFICATION',
{
title: `Under consideration for ${award.name}`,
message: input.customMessage || '',
metadata: {
projectName: e.project.title,
awardName: award.name,
customMessage: input.customMessage,
accountUrl,
},
items.push({
email: recipient.email,
name: recipient.name || '',
type: 'AWARD_SELECTION_NOTIFICATION',
context: {
title: `Under consideration for ${award.name}`,
message: input.customMessage || '',
metadata: {
projectName: e.project.title,
awardName: award.name,
customMessage: input.customMessage,
accountUrl,
},
)
emailsSent++
} catch (err) {
console.error(`[award-notify] Failed to email ${recipient.email}:`, err)
emailsFailed++
}
},
projectId: e.projectId,
userId: recipient.id,
})
}
eligibilityEmailMap.set(e.id, emails)
}
// Stamp notifiedAt on all processed eligibilities to prevent re-notification
const notifiedIds = eligibilities.map((e) => e.id)
if (notifiedIds.length > 0) {
const result = await sendBatchNotifications(items)
// Determine which eligibilities had zero failures
const failedEmails = new Set(result.errors.map((e) => e.email))
const successfulEligibilityIds: string[] = []
for (const [eligId, emails] of eligibilityEmailMap) {
const hasFailure = [...emails].some((email) => failedEmails.has(email))
if (!hasFailure) successfulEligibilityIds.push(eligId)
}
if (successfulEligibilityIds.length > 0) {
await ctx.prisma.awardEligibility.updateMany({
where: { id: { in: notifiedIds } },
where: { id: { in: successfulEligibilityIds } },
data: { notifiedAt: new Date() },
})
}
@@ -1383,14 +1402,15 @@ export const specialAwardRouter = router({
detailsJson: {
action: 'NOTIFY_ELIGIBLE_PROJECTS',
eligibleCount: eligibilities.length,
emailsSent,
emailsFailed,
emailsSent: result.sent,
emailsFailed: result.failed,
failedRecipients: result.errors.length > 0 ? result.errors.map((e) => e.email) : undefined,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return { notified: eligibilities.length, emailsSent, emailsFailed }
return { notified: successfulEligibilityIds.length, emailsSent: result.sent, emailsFailed: result.failed }
}),
/**

View 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 }
}

View File

@@ -10,12 +10,11 @@
import type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client'
import { transitionProject, isTerminalState } from './round-engine'
import { logAudit } from '@/server/utils/audit'
import {
sendStyledNotificationEmail,
getRejectionNotificationTemplate,
} from '@/lib/email'
import { getRejectionNotificationTemplate } from '@/lib/email'
import { createBulkNotifications } from '../services/in-app-notification'
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
import { sendBatchNotifications } from './notification-sender'
import type { NotificationItem } from './notification-sender'
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -724,6 +723,7 @@ export async function confirmFinalization(
const advancedUserIds = new Set<string>()
const rejectedUserIds = new Set<string>()
const notificationItems: NotificationItem[] = []
for (const prs of finalizedStates) {
type Recipient = { email: string; name: string | null; userId: string | null }
@@ -748,53 +748,56 @@ export async function confirmFinalization(
}
for (const recipient of recipients) {
try {
if (prs.state === 'PASSED') {
// Build account creation URL for passwordless users
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
if (prs.state === 'PASSED') {
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'ADVANCEMENT_NOTIFICATION',
{
title: 'Your project has advanced!',
message: '',
linkUrl: accountUrl || '/applicant',
metadata: {
projectName: prs.project.title,
fromRoundName: round.name,
toRoundName: targetRoundName,
customMessage: options.advancementMessage || undefined,
accountUrl,
},
notificationItems.push({
email: recipient.email,
name: recipient.name || '',
type: 'ADVANCEMENT_NOTIFICATION',
context: {
title: 'Your project has advanced!',
message: '',
linkUrl: accountUrl || '/applicant',
metadata: {
projectName: prs.project.title,
fromRoundName: round.name,
toRoundName: targetRoundName,
customMessage: options.advancementMessage || undefined,
accountUrl,
},
)
} else {
await sendStyledNotificationEmail(
recipient.email,
recipient.name || '',
'REJECTION_NOTIFICATION',
{
title: `Update on your application: "${prs.project.title}"`,
message: '',
metadata: {
projectName: prs.project.title,
roundName: round.name,
customMessage: options.rejectionMessage || undefined,
},
},
projectId: prs.projectId,
userId: recipient.userId || undefined,
roundId: round.id,
})
} else {
notificationItems.push({
email: recipient.email,
name: recipient.name || '',
type: 'REJECTION_NOTIFICATION',
context: {
title: `Update on your application: "${prs.project.title}"`,
message: '',
metadata: {
projectName: prs.project.title,
roundName: round.name,
customMessage: options.rejectionMessage || undefined,
},
)
}
emailsSent++
} catch (err) {
console.error(`[Finalization] Email failed for ${recipient.email}:`, err)
emailsFailed++
},
projectId: prs.projectId,
userId: recipient.userId || undefined,
roundId: round.id,
})
}
}
}
const batchResult = await sendBatchNotifications(notificationItems)
emailsSent = batchResult.sent
emailsFailed = batchResult.failed
// Create in-app notifications
if (advancedUserIds.size > 0) {
void createBulkNotifications({

View File

@@ -80,7 +80,7 @@ export async function getImageUploadUrl(
if (!isValidImageType(contentType)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP',
message: `Invalid image type: "${contentType}". Allowed: JPEG, PNG, GIF, WebP, SVG`,
})
}

View File

@@ -0,0 +1,35 @@
import { createStorageProvider, type StorageProviderType } from '@/lib/storage'
/**
* Generate a pre-signed download URL for a project logo.
* Returns null if the project has no logo.
*/
export async function getProjectLogoUrl(
logoKey: string | null | undefined,
logoProvider: string | null | undefined
): Promise<string | null> {
if (!logoKey) return null
try {
const providerType = (logoProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
return await provider.getDownloadUrl(logoKey)
} catch {
return null
}
}
/**
* Batch-generate logo URLs for multiple projects.
* Adds `logoUrl` field to each project object.
*/
export async function attachProjectLogoUrls<
T extends { logoKey?: string | null; logoProvider?: string | null }
>(projects: T[]): Promise<(T & { logoUrl: string | null })[]> {
return Promise.all(
projects.map(async (project) => ({
...project,
logoUrl: await getProjectLogoUrl(project.logoKey, project.logoProvider),
}))
)
}

View File

@@ -117,12 +117,14 @@ export const EvaluationConfigSchema = z.object({
showGlobalScore: z.boolean().default(false),
showCriterionScores: z.boolean().default(false),
showFeedbackText: z.boolean().default(false),
hideFromRejected: z.boolean().default(false),
})
.default({
enabled: false,
showGlobalScore: false,
showCriterionScores: false,
showFeedbackText: false,
hideFromRejected: false,
}),
advancementMode: z