Compare commits
4 Commits
8f2f054c57
...
267d26581d
| Author | SHA1 | Date | |
|---|---|---|---|
| 267d26581d | |||
| a39e27f6ff | |||
| 1103d42439 | |||
| f24bea3df2 |
@@ -0,0 +1,31 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" DROP CONSTRAINT "NotificationLog_userId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "NotificationLog" ADD COLUMN "batchId" TEXT,
|
||||||
|
ADD COLUMN "email" TEXT,
|
||||||
|
ADD COLUMN "projectId" TEXT,
|
||||||
|
ADD COLUMN "roundId" TEXT,
|
||||||
|
ALTER COLUMN "userId" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "channel" SET DEFAULT 'EMAIL';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_roundId_type_idx" ON "NotificationLog"("roundId", "type");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_projectId_idx" ON "NotificationLog"("projectId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_batchId_idx" ON "NotificationLog"("batchId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NotificationLog_email_idx" ON "NotificationLog"("email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -638,6 +638,7 @@ model Project {
|
|||||||
deliberationVotes DeliberationVote[]
|
deliberationVotes DeliberationVote[]
|
||||||
deliberationResults DeliberationResult[]
|
deliberationResults DeliberationResult[]
|
||||||
submissionPromotions SubmissionPromotionEvent[]
|
submissionPromotions SubmissionPromotionEvent[]
|
||||||
|
notificationLogs NotificationLog[]
|
||||||
|
|
||||||
@@index([programId])
|
@@index([programId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -931,22 +932,34 @@ model AIUsageLog {
|
|||||||
|
|
||||||
model NotificationLog {
|
model NotificationLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String?
|
||||||
channel NotificationChannel
|
channel NotificationChannel @default(EMAIL)
|
||||||
provider String? // META, TWILIO, SMTP
|
provider String? // META, TWILIO, SMTP
|
||||||
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION
|
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION, ADVANCEMENT_NOTIFICATION, etc.
|
||||||
status String // PENDING, SENT, DELIVERED, FAILED
|
status String // PENDING, SENT, DELIVERED, FAILED
|
||||||
externalId String? // Message ID from provider
|
externalId String? // Message ID from provider
|
||||||
errorMsg String? @db.Text
|
errorMsg String? @db.Text
|
||||||
|
|
||||||
|
// Bulk notification tracking
|
||||||
|
email String? // Recipient email address
|
||||||
|
roundId String?
|
||||||
|
projectId String?
|
||||||
|
batchId String? // Groups emails from same send operation
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
|
||||||
|
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
|
@@index([roundId, type])
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([batchId])
|
||||||
|
@@index([email])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -2233,6 +2246,7 @@ model Round {
|
|||||||
evaluationSummaries EvaluationSummary[]
|
evaluationSummaries EvaluationSummary[]
|
||||||
evaluationDiscussions EvaluationDiscussion[]
|
evaluationDiscussions EvaluationDiscussion[]
|
||||||
messages Message[]
|
messages Message[]
|
||||||
|
notificationLogs NotificationLog[]
|
||||||
cohorts Cohort[]
|
cohorts Cohort[]
|
||||||
liveCursor LiveProgressCursor?
|
liveCursor LiveProgressCursor?
|
||||||
|
|
||||||
|
|||||||
112
scripts/backfill-intake-round.ts
Normal file
112
scripts/backfill-intake-round.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* Backfill all projects into the intake round (and any intermediate rounds
|
||||||
|
* between intake and their earliest assigned round) with COMPLETED state.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/backfill-intake-round.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling intake round states...\n')
|
||||||
|
|
||||||
|
// Find the intake round
|
||||||
|
const intakeRound = await prisma.round.findFirst({
|
||||||
|
where: { roundType: 'INTAKE' },
|
||||||
|
select: { id: true, name: true, sortOrder: true, competitionId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!intakeRound) {
|
||||||
|
console.log('❌ No INTAKE round found')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Intake round: "${intakeRound.name}" (sortOrder: ${intakeRound.sortOrder})`)
|
||||||
|
|
||||||
|
// Get all rounds in the competition ordered by sortOrder
|
||||||
|
const allRounds = await prisma.round.findMany({
|
||||||
|
where: { competitionId: intakeRound.competitionId },
|
||||||
|
select: { id: true, name: true, sortOrder: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find all projects NOT in the intake round
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
projectRoundStates: {
|
||||||
|
none: { roundId: intakeRound.id },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
projectRoundStates: {
|
||||||
|
select: { roundId: true, round: { select: { sortOrder: true } } },
|
||||||
|
orderBy: { round: { sortOrder: 'asc' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`${projects.length} projects not in intake round\n`)
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
console.log('✅ All projects already in intake round')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each project, create COMPLETED states for intake + any intermediate rounds
|
||||||
|
const toCreate: Array<{ projectId: string; roundId: string; state: 'COMPLETED' }> = []
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
// Find the earliest round this project is already in
|
||||||
|
const earliestSortOrder = project.projectRoundStates.length > 0
|
||||||
|
? Math.min(...project.projectRoundStates.map(ps => ps.round.sortOrder))
|
||||||
|
: Infinity
|
||||||
|
|
||||||
|
const existingRoundIds = new Set(project.projectRoundStates.map(ps => ps.roundId))
|
||||||
|
|
||||||
|
// Add COMPLETED for intake + all intermediate rounds before the earliest assigned round
|
||||||
|
for (const round of allRounds) {
|
||||||
|
if (round.sortOrder >= earliestSortOrder) break
|
||||||
|
if (existingRoundIds.has(round.id)) continue
|
||||||
|
|
||||||
|
toCreate.push({
|
||||||
|
projectId: project.id,
|
||||||
|
roundId: round.id,
|
||||||
|
state: 'COMPLETED',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Creating ${toCreate.length} ProjectRoundState records...`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.projectRoundState.createMany({
|
||||||
|
data: toCreate,
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary by round
|
||||||
|
const byRound = new Map<string, number>()
|
||||||
|
for (const r of toCreate) {
|
||||||
|
const name = allRounds.find(ar => ar.id === r.roundId)?.name ?? r.roundId
|
||||||
|
byRound.set(name, (byRound.get(name) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
for (const [name, count] of byRound) {
|
||||||
|
console.log(` ${name}: ${count} projects`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Done! ${toCreate.length} records ${dryRun ? 'would be' : ''} created`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Error:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
78
scripts/backfill-team-leads.ts
Normal file
78
scripts/backfill-team-leads.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Backfill TeamMember records for all projects that have a submittedByUserId
|
||||||
|
* but no corresponding TeamMember link.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/backfill-team-leads.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling team leads...\n')
|
||||||
|
|
||||||
|
// Find all projects with a submitter but no TeamMember link for that user
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
submittedByUserId: { not: null },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
submittedByUserId: true,
|
||||||
|
teamMembers: {
|
||||||
|
select: { userId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let alreadyLinked = 0
|
||||||
|
let noSubmitter = 0
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
if (!project.submittedByUserId) {
|
||||||
|
noSubmitter++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyHasLink = project.teamMembers.some(
|
||||||
|
(tm) => tm.userId === project.submittedByUserId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (alreadyHasLink) {
|
||||||
|
alreadyLinked++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` + Linking "${project.title}" → user ${project.submittedByUserId}`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
projectId: project.id,
|
||||||
|
userId: project.submittedByUserId,
|
||||||
|
role: 'LEAD',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✅ Done!`)
|
||||||
|
console.log(` ${created} TeamMember records ${dryRun ? 'would be' : ''} created`)
|
||||||
|
console.log(` ${alreadyLinked} projects already had the submitter linked`)
|
||||||
|
console.log(` ${noSubmitter} projects had no submitter`)
|
||||||
|
console.log(` ${projects.length} total projects checked`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Error:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
32
scripts/check-invites.cjs
Normal file
32
scripts/check-invites.cjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const members = await p.teamMember.findMany({
|
||||||
|
orderBy: { joinedAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true, status: true, inviteToken: true } },
|
||||||
|
project: { select: { title: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const m of members) {
|
||||||
|
console.log(m.role, '|', m.user.name, '|', m.user.email, '|', m.user.status, '|', m.project.title, '|', m.joinedAt.toISOString().slice(0,16), '| token:', m.user.inviteToken ? 'yes' : 'no');
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await p.notificationLog.findMany({
|
||||||
|
where: { type: 'TEAM_INVITATION' },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
if (logs.length) {
|
||||||
|
console.log('\n--- Notification logs:');
|
||||||
|
for (const l of logs) {
|
||||||
|
console.log(l.status, '|', l.channel, '|', l.errorMsg, '|', l.createdAt.toISOString().slice(0,16));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('\n--- No TEAM_INVITATION notification logs found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
20
scripts/check-rounds.cjs
Normal file
20
scripts/check-rounds.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const rounds = await p.round.findMany({
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
select: { id: true, name: true, roundType: true, status: true, sortOrder: true, competitionId: true },
|
||||||
|
});
|
||||||
|
for (const r of rounds) console.log(r.sortOrder, '|', r.name, '|', r.roundType, '|', r.status, '|', r.id);
|
||||||
|
|
||||||
|
console.log('\n--- File Requirements:');
|
||||||
|
const reqs = await p.fileRequirement.findMany({ include: { round: { select: { name: true } } } });
|
||||||
|
for (const r of reqs) console.log(r.round.name, '|', r.name, '|', r.isRequired, '|', r.id);
|
||||||
|
|
||||||
|
console.log('\n--- Submission Windows:');
|
||||||
|
const wins = await p.submissionWindow.findMany({ select: { id: true, name: true, roundNumber: true, windowOpenAt: true, windowCloseAt: true, competitionId: true } });
|
||||||
|
for (const w of wins) console.log(w.name, '| round#', w.roundNumber, '| open:', w.windowOpenAt?.toISOString().slice(0,16), '| close:', w.windowCloseAt?.toISOString().slice(0,16));
|
||||||
|
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
71
scripts/create-requirements.cjs
Normal file
71
scripts/create-requirements.cjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// R2 - AI Screening round ID
|
||||||
|
const roundId = 'cmmafe7et00ldy53kxpdhhvf0';
|
||||||
|
|
||||||
|
// Check existing
|
||||||
|
const existing = await p.fileRequirement.count({ where: { roundId } });
|
||||||
|
if (existing > 0) {
|
||||||
|
console.log(`Round already has ${existing} file requirements, skipping.`);
|
||||||
|
await p.$disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requirements = [
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Executive Summary',
|
||||||
|
description: 'A 2-page executive summary of your project (PDF format, max 10MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 10,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Business Plan',
|
||||||
|
description: 'Full business plan or project proposal (PDF format, max 25MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 25,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Pitch Presentation',
|
||||||
|
description: 'Slide deck presenting your project (PDF or PowerPoint, max 50MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||||||
|
maxSizeMB: 50,
|
||||||
|
isRequired: true,
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Video Pitch',
|
||||||
|
description: 'A short video (max 3 minutes) explaining your project (MP4, max 200MB). Optional but recommended.',
|
||||||
|
acceptedMimeTypes: ['video/mp4', 'video/quicktime', 'video/webm'],
|
||||||
|
maxSizeMB: 200,
|
||||||
|
isRequired: false,
|
||||||
|
sortOrder: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roundId,
|
||||||
|
name: 'Supporting Documents',
|
||||||
|
description: 'Any additional supporting documents such as research papers, letters of support, etc. (PDF, max 20MB)',
|
||||||
|
acceptedMimeTypes: ['application/pdf'],
|
||||||
|
maxSizeMB: 20,
|
||||||
|
isRequired: false,
|
||||||
|
sortOrder: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const req of requirements) {
|
||||||
|
const created = await p.fileRequirement.create({ data: req });
|
||||||
|
console.log('Created:', created.name, '| required:', created.isRequired, '| id:', created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone! Created', requirements.length, 'file requirements for R2.');
|
||||||
|
await p.$disconnect();
|
||||||
|
})();
|
||||||
68
scripts/create-test-applicant.ts
Normal file
68
scripts/create-test-applicant.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import { sendInvitationEmail } from '../src/lib/email'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Find a program to attach the project to
|
||||||
|
const program = await prisma.program.findFirst()
|
||||||
|
if (!program) throw new Error('No program found - run seed first')
|
||||||
|
|
||||||
|
// Create applicant user
|
||||||
|
const inviteToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_applicant_matt_ciaccio',
|
||||||
|
name: 'Matt Ciaccio',
|
||||||
|
email: 'matt.ciaccio@gmail.com',
|
||||||
|
role: 'APPLICANT',
|
||||||
|
roles: ['APPLICANT'],
|
||||||
|
status: 'INVITED',
|
||||||
|
mustSetPassword: true,
|
||||||
|
inviteToken,
|
||||||
|
inviteTokenExpiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created user:', user.id)
|
||||||
|
|
||||||
|
// Create test project
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_project_qa',
|
||||||
|
title: 'OceanWatch AI',
|
||||||
|
description: 'AI-powered ocean monitoring platform for marine conservation',
|
||||||
|
programId: program.id,
|
||||||
|
submittedByUserId: user.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created project:', project.id)
|
||||||
|
|
||||||
|
// Create team member (LEAD)
|
||||||
|
await prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
id: 'test_tm_lead',
|
||||||
|
projectId: project.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: 'LEAD',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
console.log('Created team member (LEAD)')
|
||||||
|
|
||||||
|
// Send styled invitation email
|
||||||
|
const url = `http://localhost:3000/accept-invite?token=${inviteToken}`
|
||||||
|
console.log('Invite URL:', url)
|
||||||
|
|
||||||
|
await sendInvitationEmail(
|
||||||
|
'matt.ciaccio@gmail.com',
|
||||||
|
'Matt Ciaccio',
|
||||||
|
url,
|
||||||
|
'APPLICANT',
|
||||||
|
72
|
||||||
|
)
|
||||||
|
console.log('Styled invitation email sent!')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect().then(() => process.exit(0)))
|
||||||
165
scripts/seed-notification-log.ts
Normal file
165
scripts/seed-notification-log.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* Seed NotificationLog with confirmed SMTP delivery data.
|
||||||
|
*
|
||||||
|
* Sources:
|
||||||
|
* 1. 33 emails confirmed delivered in Poste.io SMTP logs (2026-03-04)
|
||||||
|
* 2. Users with status ACTIVE who are LEADs on PASSED projects
|
||||||
|
* (they clearly received and used their invite link)
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/seed-notification-log.ts
|
||||||
|
* Add --dry-run to preview without making changes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
const dryRun = process.argv.includes('--dry-run')
|
||||||
|
|
||||||
|
// Emails confirmed delivered via SMTP logs on 2026-03-04
|
||||||
|
const CONFIRMED_SMTP_EMAILS = new Set([
|
||||||
|
'fbayong@balazstudio.com',
|
||||||
|
'gnoel@kilimora.africa',
|
||||||
|
'amal.chebbi@pigmentoco.com',
|
||||||
|
'nairita@yarsi.net',
|
||||||
|
'martin.itamalo@greenbrinetechnologies.com',
|
||||||
|
'petervegan1223@gmail.com',
|
||||||
|
'dmarinov@redget.io',
|
||||||
|
'adrien@seavium.com',
|
||||||
|
'l.buob@whisper-ef.com',
|
||||||
|
'silvia@omnivorus.com',
|
||||||
|
'marzettisebastian@gmail.com',
|
||||||
|
'fiona.mcomish@algae-scope.com',
|
||||||
|
'karimeguillen@rearvora.com',
|
||||||
|
'info@skywatt.tech',
|
||||||
|
'julia@nereia-coatings.com',
|
||||||
|
'info@janmaisenbacher.com',
|
||||||
|
'xbm_0201@qq.com',
|
||||||
|
'irinakharitonova0201@gmail.com',
|
||||||
|
'seablocksrecif@gmail.com',
|
||||||
|
'oscar@seafuser.com',
|
||||||
|
'charles.maher@blueshadow.dk',
|
||||||
|
'sabirabokhari@gmail.com',
|
||||||
|
'munayimbabura@gmail.com',
|
||||||
|
'amritha.ramadevu@edu.escp.eu',
|
||||||
|
'nele.jordan@myhsba.de',
|
||||||
|
'karl.mihhels@aalto.fi',
|
||||||
|
'christine.a.kurz@gmail.com',
|
||||||
|
'aki@corall.eco',
|
||||||
|
'topias.kilpinen@hotmail.fi',
|
||||||
|
'nina.riutta.camilla@gmail.com',
|
||||||
|
'sofie.boggiosella@my.jcu.edu.au',
|
||||||
|
'giambattistafigari@gmail.com',
|
||||||
|
'mussinig0@gmail.com',
|
||||||
|
])
|
||||||
|
|
||||||
|
const SENT_AT = new Date('2026-03-04T01:00:00Z')
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(dryRun ? '--- DRY RUN ---\n' : 'Seeding NotificationLog...\n')
|
||||||
|
|
||||||
|
// Find LEAD team members on PASSED projects
|
||||||
|
const passedLeads = await prisma.teamMember.findMany({
|
||||||
|
where: {
|
||||||
|
role: 'LEAD',
|
||||||
|
project: {
|
||||||
|
projectRoundStates: {
|
||||||
|
some: { state: 'PASSED' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
projectId: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
projectRoundStates: {
|
||||||
|
where: { state: 'PASSED' },
|
||||||
|
select: { roundId: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
status: true,
|
||||||
|
inviteToken: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Found ${passedLeads.length} LEAD team members on PASSED projects\n`)
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let skipped = 0
|
||||||
|
|
||||||
|
for (const lead of passedLeads) {
|
||||||
|
const email = lead.user.email?.toLowerCase()
|
||||||
|
if (!email) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a NotificationLog already exists for this project+email
|
||||||
|
const existing = await prisma.notificationLog.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
projectId: lead.projectId,
|
||||||
|
type: 'ADVANCEMENT_NOTIFICATION',
|
||||||
|
status: 'SENT',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine confidence of delivery
|
||||||
|
const isConfirmedSMTP = CONFIRMED_SMTP_EMAILS.has(email)
|
||||||
|
const isActive = lead.user.status === 'ACTIVE'
|
||||||
|
const isInvited = lead.user.status === 'INVITED' && !!lead.user.inviteToken
|
||||||
|
|
||||||
|
// Only seed for confirmed deliveries or active users
|
||||||
|
if (!isConfirmedSMTP && !isActive && !isInvited) {
|
||||||
|
console.log(` SKIP ${email} (status=${lead.user.status}, not in SMTP logs)`)
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundId = lead.project.projectRoundStates[0]?.roundId ?? null
|
||||||
|
const label = isConfirmedSMTP ? 'SMTP-confirmed' : isActive ? 'user-active' : 'invite-sent'
|
||||||
|
|
||||||
|
console.log(` ${dryRun ? 'WOULD CREATE' : 'CREATE'} ${email} [${label}] project=${lead.projectId}`)
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
await prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: lead.user.id,
|
||||||
|
channel: 'EMAIL',
|
||||||
|
type: 'ADVANCEMENT_NOTIFICATION',
|
||||||
|
status: 'SENT',
|
||||||
|
email,
|
||||||
|
projectId: lead.projectId,
|
||||||
|
roundId,
|
||||||
|
batchId: 'seed-2026-03-04',
|
||||||
|
createdAt: SENT_AT,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
created++
|
||||||
|
} else {
|
||||||
|
created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nDone. Created: ${created}, Skipped: ${skipped}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Error:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
120
scripts/send-invite-direct.ts
Normal file
120
scripts/send-invite-direct.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
// Import just the template helper without hitting DB
|
||||||
|
// We'll construct the email manually since the DB connection fails
|
||||||
|
|
||||||
|
const BRAND = {
|
||||||
|
red: '#de0f1e',
|
||||||
|
darkBlue: '#053d57',
|
||||||
|
white: '#fefefe',
|
||||||
|
teal: '#557f8c',
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
|
||||||
|
const url = 'http://localhost:3000/accept-invite?token=' + token;
|
||||||
|
|
||||||
|
// Replicate the styled email template from email.ts
|
||||||
|
function getStyledHtml(name: string, inviteUrl: string) {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>You're invited to join the MOPC Portal</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 40px 20px;">
|
||||||
|
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; width: 100%;">
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, ${BRAND.teal} 100%); border-radius: 16px 16px 0 0; padding: 32px 40px; text-align: center;">
|
||||||
|
<h1 style="color: ${BRAND.white}; font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em;">
|
||||||
|
Monaco Ocean Protection Challenge
|
||||||
|
</h1>
|
||||||
|
<p style="color: rgba(255,255,255,0.8); font-size: 13px; font-weight: 300; margin: 8px 0 0 0; letter-spacing: 0.05em; text-transform: uppercase;">
|
||||||
|
Together for a healthier ocean
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #ffffff; padding: 40px; border-radius: 0 0 16px 16px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);">
|
||||||
|
<h2 style="color: ${BRAND.darkBlue}; font-size: 20px; font-weight: 600; margin: 0 0 24px 0;">
|
||||||
|
Hello ${name},
|
||||||
|
</h2>
|
||||||
|
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 16px 0; font-weight: 400;">
|
||||||
|
You've been invited to join the Monaco Ocean Protection Challenge platform as an <strong>applicant</strong>.
|
||||||
|
</p>
|
||||||
|
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 24px 0; font-weight: 400;">
|
||||||
|
Click the button below to set up your account and get started.
|
||||||
|
</p>
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 28px 0;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="${inviteUrl}" style="display: inline-block; background: linear-gradient(135deg, ${BRAND.red} 0%, #c40d19 100%); color: #ffffff; text-decoration: none; padding: 14px 36px; border-radius: 10px; font-size: 15px; font-weight: 600; letter-spacing: 0.02em; box-shadow: 0 4px 14px rgba(222, 15, 30, 0.3);">
|
||||||
|
Accept Invitation
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!-- Info Box -->
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
|
<tr>
|
||||||
|
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||||
|
<p style="color: #1e40af; margin: 0; font-size: 13px; line-height: 1.6;">
|
||||||
|
This link will expire in 3 days.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 24px 40px; text-align: center;">
|
||||||
|
<p style="color: #94a3b8; font-size: 12px; line-height: 1.6; margin: 0;">
|
||||||
|
Monaco Ocean Protection Challenge<br>
|
||||||
|
<span style="color: #cbd5e1;">Together for a healthier ocean.</span>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Creating transporter...');
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: 'mail.monaco-opc.com',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: 'noreply@monaco-opc.com',
|
||||||
|
pass: '9EythPDcz1Fya4M88iigkB1wojNf8QEVPuRRnD9dJMBpT3pk2',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Sending styled invitation email...');
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: 'MOPC Portal <noreply@monaco-opc.com>',
|
||||||
|
to: 'matt.ciaccio@gmail.com',
|
||||||
|
subject: "You're invited to join the MOPC Portal",
|
||||||
|
text: `Hello Matt Ciaccio,\n\nYou've been invited to join the Monaco Ocean Protection Challenge platform as an applicant.\n\nClick the link below to set up your account:\n\n${url}\n\nThis link will expire in 3 days.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||||
|
html: getStyledHtml('Matt Ciaccio', url),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('SUCCESS! Message ID:', info.messageId);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('FAILED:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
26
scripts/send-invite.ts
Normal file
26
scripts/send-invite.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { sendInvitationEmail } from '../src/lib/email';
|
||||||
|
|
||||||
|
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
|
||||||
|
const url = 'http://localhost:3000/accept-invite?token=' + token;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Sending styled invitation email...');
|
||||||
|
console.log('To: matt.ciaccio@gmail.com');
|
||||||
|
console.log('URL:', url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendInvitationEmail(
|
||||||
|
'matt.ciaccio@gmail.com',
|
||||||
|
'Matt Ciaccio',
|
||||||
|
url,
|
||||||
|
'APPLICANT',
|
||||||
|
72
|
||||||
|
);
|
||||||
|
console.log('SUCCESS: Styled invitation email sent!');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('FAILED:', err.message || err);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
20
scripts/test-db.cjs
Normal file
20
scripts/test-db.cjs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const { PrismaClient } = require('@prisma/client');
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||||
|
const p = new PrismaClient({ log: ['query', 'info', 'warn', 'error'] });
|
||||||
|
try {
|
||||||
|
const result = await p.$queryRawUnsafe('SELECT 1 as ok');
|
||||||
|
console.log('Connected!', result);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error code:', e.code);
|
||||||
|
console.error('Error meta:', JSON.stringify(e.meta, null, 2));
|
||||||
|
console.error('Message:', e.message);
|
||||||
|
} finally {
|
||||||
|
await p.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -23,6 +23,29 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} 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 { FileViewer } from '@/components/shared/file-viewer'
|
||||||
import { FileUpload } from '@/components/shared/file-upload'
|
import { FileUpload } from '@/components/shared/file-upload'
|
||||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||||
@@ -37,7 +60,6 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
@@ -50,9 +72,11 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
ScanSearch,
|
ScanSearch,
|
||||||
Eye,
|
Eye,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { formatDate, formatDateOnly } from '@/lib/utils'
|
import { formatDateOnly } from '@/lib/utils'
|
||||||
|
|
||||||
interface PageProps {
|
interface PageProps {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
@@ -121,6 +145,42 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
|
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) {
|
if (isLoading) {
|
||||||
return <ProjectDetailSkeleton />
|
return <ProjectDetailSkeleton />
|
||||||
}
|
}
|
||||||
@@ -184,9 +244,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
{project.title}
|
{project.title}
|
||||||
</h1>
|
</h1>
|
||||||
<Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}>
|
{(() => {
|
||||||
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
|
const prs = (project as any).projectRoundStates ?? []
|
||||||
</Badge>
|
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>
|
</div>
|
||||||
{project.teamName && (
|
{project.teamName && (
|
||||||
<p className="text-muted-foreground">{project.teamName}</p>
|
<p className="text-muted-foreground">{project.teamName}</p>
|
||||||
@@ -430,7 +494,6 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Team Members Section */}
|
{/* Team Members Section */}
|
||||||
{project.teamMembers && project.teamMembers.length > 0 && (
|
|
||||||
<AnimatedCard index={2}>
|
<AnimatedCard index={2}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -439,13 +502,22 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||||
<Users className="h-4 w-4 text-violet-500" />
|
<Users className="h-4 w-4 text-violet-500" />
|
||||||
</div>
|
</div>
|
||||||
Team Members ({project.teamMembers.length})
|
Team Members ({project.teamMembers?.length ?? 0})
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setAddMemberOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Add Member
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{project.teamMembers && project.teamMembers.length > 0 ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<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 } }) => (
|
{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">
|
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
||||||
{member.role === 'LEAD' ? (
|
{member.role === 'LEAD' ? (
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||||
@@ -470,13 +542,155 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||||||
<p className="text-xs text-muted-foreground">{member.title}</p>
|
<p className="text-xs text-muted-foreground">{member.title}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No team members yet.</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</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 */}
|
{/* Mentor Assignment Section */}
|
||||||
{project.wantsMentorship && (
|
{project.wantsMentorship && (
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import {
|
|||||||
ArrowRightCircle,
|
ArrowRightCircle,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
|
Bell,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -90,7 +91,8 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { truncate } from '@/lib/utils'
|
import { truncate } from '@/lib/utils'
|
||||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
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 { Pagination } from '@/components/shared/pagination'
|
||||||
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
||||||
import { CountryFlagImg } from '@/components/ui/country-select'
|
import { CountryFlagImg } from '@/components/ui/country-select'
|
||||||
@@ -113,6 +115,25 @@ const statusColors: Record<
|
|||||||
WINNER: 'success',
|
WINNER: 'success',
|
||||||
REJECTED: 'destructive',
|
REJECTED: 'destructive',
|
||||||
WITHDRAWN: 'secondary',
|
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(
|
function parseFiltersFromParams(
|
||||||
@@ -290,6 +311,7 @@ export default function ProjectsPage() {
|
|||||||
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
|
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
|
||||||
const [assignRoundId, setAssignRoundId] = useState('')
|
const [assignRoundId, setAssignRoundId] = useState('')
|
||||||
|
|
||||||
|
const [bulkNotifyOpen, setBulkNotifyOpen] = useState(false)
|
||||||
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
|
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
|
||||||
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
|
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
|
||||||
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
|
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
|
||||||
@@ -619,6 +641,13 @@ export default function ProjectsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<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
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setAiTagDialogOpen(true)}
|
onClick={() => setAiTagDialogOpen(true)}
|
||||||
@@ -713,7 +742,7 @@ export default function ProjectsPage() {
|
|||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
{Object.entries(data.statusCounts ?? {})
|
{Object.entries(data.statusCounts ?? {})
|
||||||
.sort(([a], [b]) => {
|
.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)
|
return order.indexOf(a) - order.indexOf(b)
|
||||||
})
|
})
|
||||||
.map(([status, count]) => (
|
.map(([status, count]) => (
|
||||||
@@ -873,7 +902,7 @@ export default function ProjectsPage() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.projects.map((project) => {
|
{data.projects.map((project) => {
|
||||||
const isEliminated = project.status === 'REJECTED'
|
const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={project.id}
|
key={project.id}
|
||||||
@@ -894,6 +923,7 @@ export default function ProjectsPage() {
|
|||||||
>
|
>
|
||||||
<ProjectLogo
|
<ProjectLogo
|
||||||
project={project}
|
project={project}
|
||||||
|
logoUrl={project.logoUrl}
|
||||||
size="sm"
|
size="sm"
|
||||||
fallback="initials"
|
fallback="initials"
|
||||||
/>
|
/>
|
||||||
@@ -972,7 +1002,10 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
{(() => {
|
||||||
|
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
|
||||||
|
return <Badge variant={derived.variant}>{derived.label}</Badge>
|
||||||
|
})()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="relative z-10 text-right">
|
<TableCell className="relative z-10 text-right">
|
||||||
<DropdownMenu>
|
<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">
|
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start gap-3 pl-8">
|
<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-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||||
{project.title}
|
{project.title}
|
||||||
</CardTitle>
|
</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>
|
</div>
|
||||||
<CardDescription>{project.teamName}</CardDescription>
|
<CardDescription>{project.teamName}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
@@ -1096,7 +1132,7 @@ export default function ProjectsPage() {
|
|||||||
/* Card View */
|
/* Card View */
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{data.projects.map((project) => {
|
{data.projects.map((project) => {
|
||||||
const isEliminated = project.status === 'REJECTED'
|
const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
|
||||||
return (
|
return (
|
||||||
<div key={project.id} className="relative">
|
<div key={project.id} className="relative">
|
||||||
<div className="absolute left-3 top-3 z-10">
|
<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' : ''}`}>
|
<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">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start gap-3 pl-7">
|
<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-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||||
@@ -1177,7 +1213,10 @@ export default function ProjectsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 pt-0">
|
<CardContent className="space-y-3 pt-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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 && (
|
{project.competitionCategory && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
@@ -1846,6 +1885,8 @@ export default function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<BulkNotificationDialog open={bulkNotifyOpen} onOpenChange={setBulkNotifyOpen} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useSession } from 'next-auth/react'
|
import { useSession } from 'next-auth/react'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
Video,
|
Video,
|
||||||
File,
|
File,
|
||||||
Download,
|
Download,
|
||||||
|
Eye,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const fileTypeIcons: Record<string, typeof FileText> = {
|
const fileTypeIcons: Record<string, typeof FileText> = {
|
||||||
@@ -42,6 +44,34 @@ const fileTypeLabels: Record<string, string> = {
|
|||||||
SUPPORTING_DOC: 'Supporting Document',
|
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() {
|
export default function ApplicantDocumentsPage() {
|
||||||
const { status: sessionStatus } = useSession()
|
const { status: sessionStatus } = useSession()
|
||||||
const isAuthenticated = sessionStatus === 'authenticated'
|
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
|
const isDraft = !project.submittedAt
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -98,8 +128,20 @@ export default function ApplicantDocumentsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Per-round upload sections */}
|
||||||
{openRounds.length > 0 && (
|
{!isRejected && openRounds.length > 0 && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{openRounds.map((round: { id: string; name: string; windowCloseAt?: string | Date | null }) => {
|
{openRounds.map((round: { id: string; name: string; windowCloseAt?: string | Date | null }) => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -163,18 +205,18 @@ export default function ApplicantDocumentsPage() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{project.files.map((file) => {
|
{project.files.map((file) => {
|
||||||
const Icon = fileTypeIcons[file.fileType] || 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
className="flex items-center justify-between p-3 rounded-lg border"
|
className="flex items-center justify-between p-3 rounded-lg border"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
<Icon className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{fileRecord.isLate && (
|
||||||
<Badge variant="warning" className="text-xs gap-1">
|
<Badge variant="warning" className="text-xs gap-1">
|
||||||
<AlertTriangle className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
@@ -189,6 +231,13 @@ export default function ApplicantDocumentsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{fileRecord.bucket && fileRecord.objectKey && (
|
||||||
|
<FileActionButtons
|
||||||
|
bucket={fileRecord.bucket}
|
||||||
|
objectKey={fileRecord.objectKey}
|
||||||
|
fileName={file.fileName}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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 programYear = project.program?.year
|
||||||
const programName = project.program?.name
|
const programName = project.program?.name
|
||||||
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
|
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
|
||||||
@@ -221,8 +221,23 @@ export default function ApplicantDashboardPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Quick actions */}
|
{/* Rejected banner */}
|
||||||
|
{isRejected && (
|
||||||
<AnimatedCard index={1}>
|
<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 */}
|
||||||
|
{!isRejected && (
|
||||||
|
<AnimatedCard index={2}>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<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">
|
<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">
|
<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>
|
</div>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Document Completeness */}
|
{/* Document Completeness */}
|
||||||
{docCompleteness && docCompleteness.length > 0 && (
|
{docCompleteness && docCompleteness.length > 0 && (
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export default function ApplicantProjectPage() {
|
|||||||
const project = dashboardData?.project
|
const project = dashboardData?.project
|
||||||
const projectId = project?.id
|
const projectId = project?.id
|
||||||
const isIntakeOpen = dashboardData?.isIntakeOpen ?? false
|
const isIntakeOpen = dashboardData?.isIntakeOpen ?? false
|
||||||
|
const isRejected = dashboardData?.isRejected ?? false
|
||||||
|
|
||||||
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
|
const { data: teamData, isLoading: teamLoading, refetch } = trpc.applicant.getTeamMembers.useQuery(
|
||||||
{ projectId: projectId! },
|
{ projectId: projectId! },
|
||||||
@@ -398,7 +399,7 @@ export default function ApplicantProjectPage() {
|
|||||||
Everyone on this list can view and collaborate on this project.
|
Everyone on this list can view and collaborate on this project.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{isTeamLead && (
|
{isTeamLead && !isRejected && (
|
||||||
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
|
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button size="sm">
|
<Button size="sm">
|
||||||
@@ -578,7 +579,7 @@ export default function ApplicantProjectPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isTeamLead && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
|
{isTeamLead && !isRejected && member.role !== 'LEAD' && teamData.submittedBy?.id !== member.userId && (
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="text-destructive">
|
<Button variant="ghost" size="icon" className="text-destructive">
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ export default async function AuthLayout({
|
|||||||
// Redirect logged-in users to their dashboard
|
// Redirect logged-in users to their dashboard
|
||||||
// But NOT if they still need to set their password
|
// But NOT if they still need to set their password
|
||||||
if (session?.user && !session.user.mustSetPassword) {
|
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({
|
const dbUser = await prisma.user.findUnique({
|
||||||
where: { id: session.user.id },
|
where: { id: session.user.id },
|
||||||
select: { id: true },
|
select: { id: true, onboardingCompletedAt: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (dbUser) {
|
if (dbUser) {
|
||||||
|
|
||||||
const role = session.user.role
|
const role = session.user.role
|
||||||
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
|
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
|
||||||
redirect('/admin')
|
redirect('/admin')
|
||||||
|
|||||||
@@ -36,17 +36,9 @@ export default function SetPasswordPage() {
|
|||||||
setIsSuccess(true)
|
setIsSuccess(true)
|
||||||
// Update the session to reflect the password has been set
|
// Update the session to reflect the password has been set
|
||||||
await updateSession()
|
await updateSession()
|
||||||
// Redirect after a short delay
|
// Redirect after a short delay — all roles go to onboarding first
|
||||||
setTimeout(() => {
|
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')
|
router.push('/onboarding')
|
||||||
} else {
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
}, 2000)
|
}, 2000)
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ export function MembersContent() {
|
|||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
userStatus={user.status}
|
userStatus={user.status}
|
||||||
userRole={user.role as RoleValue}
|
userRole={user.role as RoleValue}
|
||||||
|
userRoles={(user as unknown as { roles?: RoleValue[] }).roles}
|
||||||
currentUserRole={currentUserRole}
|
currentUserRole={currentUserRole}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -524,6 +525,7 @@ export function MembersContent() {
|
|||||||
userEmail={user.email}
|
userEmail={user.email}
|
||||||
userStatus={user.status}
|
userStatus={user.status}
|
||||||
userRole={user.role as RoleValue}
|
userRole={user.role as RoleValue}
|
||||||
|
userRoles={(user as unknown as { roles?: RoleValue[] }).roles}
|
||||||
currentUserRole={currentUserRole}
|
currentUserRole={currentUserRole}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
382
src/components/admin/projects/bulk-notification-dialog.tsx
Normal file
382
src/components/admin/projects/bulk-notification-dialog.tsx
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Send,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Trophy,
|
||||||
|
Ban,
|
||||||
|
Award,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface BulkNotificationDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulkNotificationDialog({ open, onOpenChange }: BulkNotificationDialogProps) {
|
||||||
|
// Section states
|
||||||
|
const [passedOpen, setPassedOpen] = useState(true)
|
||||||
|
const [rejectedOpen, setRejectedOpen] = useState(false)
|
||||||
|
const [awardOpen, setAwardOpen] = useState(false)
|
||||||
|
|
||||||
|
// Passed section
|
||||||
|
const [passedEnabled, setPassedEnabled] = useState(true)
|
||||||
|
const [passedMessage, setPassedMessage] = useState('')
|
||||||
|
const [passedFullCustom, setPassedFullCustom] = useState(false)
|
||||||
|
|
||||||
|
// Rejected section
|
||||||
|
const [rejectedEnabled, setRejectedEnabled] = useState(false)
|
||||||
|
const [rejectedMessage, setRejectedMessage] = useState('')
|
||||||
|
const [rejectedFullCustom, setRejectedFullCustom] = useState(false)
|
||||||
|
const [rejectedIncludeInvite, setRejectedIncludeInvite] = useState(false)
|
||||||
|
|
||||||
|
// Award section
|
||||||
|
const [selectedAwardId, setSelectedAwardId] = useState<string | null>(null)
|
||||||
|
const [awardMessage, setAwardMessage] = useState('')
|
||||||
|
|
||||||
|
// Global
|
||||||
|
const [skipAlreadySent, setSkipAlreadySent] = useState(true)
|
||||||
|
|
||||||
|
// Loading states
|
||||||
|
const [sendingPassed, setSendingPassed] = useState(false)
|
||||||
|
const [sendingRejected, setSendingRejected] = useState(false)
|
||||||
|
const [sendingAward, setSendingAward] = useState(false)
|
||||||
|
const [sendingAll, setSendingAll] = useState(false)
|
||||||
|
|
||||||
|
const summary = trpc.project.getBulkNotificationSummary.useQuery(undefined, {
|
||||||
|
enabled: open,
|
||||||
|
})
|
||||||
|
|
||||||
|
const sendPassed = trpc.project.sendBulkPassedNotifications.useMutation()
|
||||||
|
const sendRejected = trpc.project.sendBulkRejectionNotifications.useMutation()
|
||||||
|
const sendAward = trpc.project.sendBulkAwardNotifications.useMutation()
|
||||||
|
|
||||||
|
const handleSendPassed = async () => {
|
||||||
|
setSendingPassed(true)
|
||||||
|
try {
|
||||||
|
const result = await sendPassed.mutateAsync({
|
||||||
|
customMessage: passedMessage || undefined,
|
||||||
|
fullCustomBody: passedFullCustom,
|
||||||
|
skipAlreadySent,
|
||||||
|
})
|
||||||
|
toast.success(`Advancement: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
|
||||||
|
summary.refetch()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to send')
|
||||||
|
} finally {
|
||||||
|
setSendingPassed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendRejected = async () => {
|
||||||
|
setSendingRejected(true)
|
||||||
|
try {
|
||||||
|
const result = await sendRejected.mutateAsync({
|
||||||
|
customMessage: rejectedMessage || undefined,
|
||||||
|
fullCustomBody: rejectedFullCustom,
|
||||||
|
includeInviteLink: rejectedIncludeInvite,
|
||||||
|
skipAlreadySent,
|
||||||
|
})
|
||||||
|
toast.success(`Rejection: ${result.sent} sent, ${result.failed} failed, ${result.skipped} skipped`)
|
||||||
|
summary.refetch()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to send')
|
||||||
|
} finally {
|
||||||
|
setSendingRejected(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendAward = async (awardId: string) => {
|
||||||
|
setSendingAward(true)
|
||||||
|
try {
|
||||||
|
const result = await sendAward.mutateAsync({
|
||||||
|
awardId,
|
||||||
|
customMessage: awardMessage || undefined,
|
||||||
|
skipAlreadySent,
|
||||||
|
})
|
||||||
|
toast.success(`Award: ${result.sent} sent, ${result.failed} failed`)
|
||||||
|
summary.refetch()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Failed to send')
|
||||||
|
} finally {
|
||||||
|
setSendingAward(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSendAll = async () => {
|
||||||
|
setSendingAll(true)
|
||||||
|
try {
|
||||||
|
if (passedEnabled && totalPassed > 0) {
|
||||||
|
await handleSendPassed()
|
||||||
|
}
|
||||||
|
if (rejectedEnabled && (summary.data?.rejected.count ?? 0) > 0) {
|
||||||
|
await handleSendRejected()
|
||||||
|
}
|
||||||
|
toast.success('All enabled notifications sent')
|
||||||
|
} catch {
|
||||||
|
// Individual handlers already toast errors
|
||||||
|
} finally {
|
||||||
|
setSendingAll(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPassed = summary.data?.passed.reduce((sum, g) => sum + g.projectCount, 0) ?? 0
|
||||||
|
const isSending = sendingPassed || sendingRejected || sendingAward || sendingAll
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk Notifications</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Send advancement, rejection, and award pool notifications to project teams.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{summary.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : summary.error ? (
|
||||||
|
<div className="text-destructive text-sm py-4">
|
||||||
|
Failed to load summary: {summary.error.message}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Global settings */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="skip-already-sent"
|
||||||
|
checked={skipAlreadySent}
|
||||||
|
onCheckedChange={setSkipAlreadySent}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="skip-already-sent" className="text-sm">
|
||||||
|
Skip already notified
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{summary.data?.alreadyNotified.advancement ?? 0} advancement + {summary.data?.alreadyNotified.rejection ?? 0} rejection already sent
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PASSED section */}
|
||||||
|
<Collapsible open={passedOpen} onOpenChange={setPassedOpen}>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{passedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
<Trophy className="h-4 w-4 text-green-600" />
|
||||||
|
<span className="font-medium">Passed / Advanced</span>
|
||||||
|
<Badge variant="secondary">{totalPassed} projects</Badge>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={passedEnabled}
|
||||||
|
onCheckedChange={setPassedEnabled}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||||
|
{summary.data?.passed.map((g) => (
|
||||||
|
<div key={g.roundId} className="text-sm flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||||
|
<span className="text-muted-foreground">{g.roundName}</span>
|
||||||
|
<span className="font-medium">{g.projectCount}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">→ {g.nextRoundName}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label className="text-xs">Custom message (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={passedMessage}
|
||||||
|
onChange={(e) => setPassedMessage(e.target.value)}
|
||||||
|
placeholder="Add a personal note to the advancement email..."
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="passed-full-custom"
|
||||||
|
checked={passedFullCustom}
|
||||||
|
onCheckedChange={setPassedFullCustom}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="passed-full-custom" className="text-xs">
|
||||||
|
Full custom body (replace default template)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSendPassed}
|
||||||
|
disabled={!passedEnabled || totalPassed === 0 || isSending}
|
||||||
|
>
|
||||||
|
{sendingPassed ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
|
||||||
|
Send Advancement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* REJECTED section */}
|
||||||
|
<Collapsible open={rejectedOpen} onOpenChange={setRejectedOpen}>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{rejectedOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
<Ban className="h-4 w-4 text-red-600" />
|
||||||
|
<span className="font-medium">Rejected / Filtered Out</span>
|
||||||
|
<Badge variant="destructive">{summary.data?.rejected.count ?? 0} projects</Badge>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={rejectedEnabled}
|
||||||
|
onCheckedChange={setRejectedEnabled}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">Custom message (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={rejectedMessage}
|
||||||
|
onChange={(e) => setRejectedMessage(e.target.value)}
|
||||||
|
placeholder="Add a personal note to the rejection email..."
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="rejected-full-custom"
|
||||||
|
checked={rejectedFullCustom}
|
||||||
|
onCheckedChange={setRejectedFullCustom}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="rejected-full-custom" className="text-xs">
|
||||||
|
Full custom body (replace default template)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="rejected-include-invite"
|
||||||
|
checked={rejectedIncludeInvite}
|
||||||
|
onCheckedChange={setRejectedIncludeInvite}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="rejected-include-invite" className="text-xs">
|
||||||
|
Include platform invite link for rejected teams
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleSendRejected}
|
||||||
|
disabled={!rejectedEnabled || (summary.data?.rejected.count ?? 0) === 0 || isSending}
|
||||||
|
>
|
||||||
|
{sendingRejected ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Send className="mr-2 h-3.5 w-3.5" />}
|
||||||
|
Send Rejections
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* AWARD POOLS section */}
|
||||||
|
<Collapsible open={awardOpen} onOpenChange={setAwardOpen}>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<CollapsibleTrigger className="flex w-full items-center justify-between p-4 hover:bg-muted/50 transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{awardOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
<Award className="h-4 w-4 text-amber-600" />
|
||||||
|
<span className="font-medium">Award Pools</span>
|
||||||
|
<Badge variant="outline">{summary.data?.awardPools.length ?? 0} awards</Badge>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="border-t px-4 pb-4 pt-3 space-y-3">
|
||||||
|
{(summary.data?.awardPools ?? []).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No award pools configured.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{summary.data?.awardPools.map((a) => (
|
||||||
|
<div key={a.awardId} className="flex items-center justify-between rounded border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Award className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
<span className="text-sm font-medium">{a.awardName}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">{a.eligibleCount} eligible</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedAwardId(a.awardId)
|
||||||
|
handleSendAward(a.awardId)
|
||||||
|
}}
|
||||||
|
disabled={a.eligibleCount === 0 || isSending}
|
||||||
|
>
|
||||||
|
{sendingAward && selectedAwardId === a.awardId ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
Notify
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<Label className="text-xs">Custom message for awards (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
value={awardMessage}
|
||||||
|
onChange={(e) => setAwardMessage(e.target.value)}
|
||||||
|
placeholder="Add a note to the award notification..."
|
||||||
|
rows={2}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* Send All button */}
|
||||||
|
<div className="flex justify-end pt-2 border-t">
|
||||||
|
<Button
|
||||||
|
onClick={handleSendAll}
|
||||||
|
disabled={(!passedEnabled && !rejectedEnabled) || isSending}
|
||||||
|
>
|
||||||
|
{sendingAll ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||||
|
Send All Enabled
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { useState } from 'react'
|
|||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Trophy } from 'lucide-react'
|
import { Trophy } from 'lucide-react'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import { EmailPreviewDialog } from './email-preview-dialog'
|
import { EmailPreviewDialog } from './email-preview-dialog'
|
||||||
|
|
||||||
interface NotifyAdvancedButtonProps {
|
interface NotifyAdvancedButtonProps {
|
||||||
@@ -14,9 +16,10 @@ interface NotifyAdvancedButtonProps {
|
|||||||
export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) {
|
export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedButtonProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [customMessage, setCustomMessage] = useState<string | undefined>()
|
const [customMessage, setCustomMessage] = useState<string | undefined>()
|
||||||
|
const [fullCustomBody, setFullCustomBody] = useState(false)
|
||||||
|
|
||||||
const preview = trpc.round.previewAdvancementEmail.useQuery(
|
const preview = trpc.round.previewAdvancementEmail.useQuery(
|
||||||
{ roundId, targetRoundId, customMessage },
|
{ roundId, targetRoundId, customMessage, fullCustomBody },
|
||||||
{ enabled: open }
|
{ enabled: open }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,9 +35,10 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
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"
|
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" />
|
<Trophy className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
@@ -44,6 +48,18 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EmailPreviewDialog
|
<EmailPreviewDialog
|
||||||
open={open}
|
open={open}
|
||||||
@@ -53,7 +69,7 @@ export function NotifyAdvancedButton({ roundId, targetRoundId }: NotifyAdvancedB
|
|||||||
recipientCount={preview.data?.recipientCount ?? 0}
|
recipientCount={preview.data?.recipientCount ?? 0}
|
||||||
previewHtml={preview.data?.html}
|
previewHtml={preview.data?.html}
|
||||||
isPreviewLoading={preview.isLoading}
|
isPreviewLoading={preview.isLoading}
|
||||||
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg })}
|
onSend={(msg) => sendMutation.mutate({ roundId, targetRoundId, customMessage: msg, fullCustomBody })}
|
||||||
isSending={sendMutation.isPending}
|
isSending={sendMutation.isPending}
|
||||||
onRefreshPreview={(msg) => setCustomMessage(msg)}
|
onRefreshPreview={(msg) => setCustomMessage(msg)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useState } from 'react'
|
|||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { XCircle } from 'lucide-react'
|
import { XCircle } from 'lucide-react'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
import { EmailPreviewDialog } from './email-preview-dialog'
|
import { EmailPreviewDialog } from './email-preview-dialog'
|
||||||
|
|
||||||
interface NotifyRejectedButtonProps {
|
interface NotifyRejectedButtonProps {
|
||||||
@@ -13,9 +15,10 @@ interface NotifyRejectedButtonProps {
|
|||||||
export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
|
export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [customMessage, setCustomMessage] = useState<string | undefined>()
|
const [customMessage, setCustomMessage] = useState<string | undefined>()
|
||||||
|
const [fullCustomBody, setFullCustomBody] = useState(false)
|
||||||
|
|
||||||
const preview = trpc.round.previewRejectionEmail.useQuery(
|
const preview = trpc.round.previewRejectionEmail.useQuery(
|
||||||
{ roundId, customMessage },
|
{ roundId, customMessage, fullCustomBody },
|
||||||
{ enabled: open }
|
{ enabled: open }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,9 +34,10 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
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"
|
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" />
|
<XCircle className="h-5 w-5 text-red-600 mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
@@ -43,6 +47,18 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EmailPreviewDialog
|
<EmailPreviewDialog
|
||||||
open={open}
|
open={open}
|
||||||
@@ -52,7 +68,7 @@ export function NotifyRejectedButton({ roundId }: NotifyRejectedButtonProps) {
|
|||||||
recipientCount={preview.data?.recipientCount ?? 0}
|
recipientCount={preview.data?.recipientCount ?? 0}
|
||||||
previewHtml={preview.data?.html}
|
previewHtml={preview.data?.html}
|
||||||
isPreviewLoading={preview.isLoading}
|
isPreviewLoading={preview.isLoading}
|
||||||
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg })}
|
onSend={(msg) => sendMutation.mutate({ roundId, customMessage: msg, fullCustomBody })}
|
||||||
isSending={sendMutation.isPending}
|
isSending={sendMutation.isPending}
|
||||||
onRefreshPreview={(msg) => setCustomMessage(msg)}
|
onRefreshPreview={(msg) => setCustomMessage(msg)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const visConfig = (config.applicantVisibility as {
|
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) => {
|
const updateVisibility = (key: string, value: unknown) => {
|
||||||
@@ -293,6 +293,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
|
||||||
Evaluations are only visible to applicants after this round closes.
|
Evaluations are only visible to applicants after this round closes.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
@@ -32,7 +33,6 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Loader2,
|
Loader2,
|
||||||
Shield,
|
Shield,
|
||||||
Check,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||||
@@ -50,10 +50,11 @@ interface UserActionsProps {
|
|||||||
userEmail: string
|
userEmail: string
|
||||||
userStatus: string
|
userStatus: string
|
||||||
userRole: Role
|
userRole: Role
|
||||||
|
userRoles?: Role[]
|
||||||
currentUserRole?: 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 [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
|
||||||
@@ -64,13 +65,13 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
|
|||||||
utils.user.list.invalidate()
|
utils.user.list.invalidate()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const updateUser = trpc.user.update.useMutation({
|
const updateRoles = trpc.user.updateRoles.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.user.list.invalidate()
|
utils.user.list.invalidate()
|
||||||
toast.success('Role updated successfully')
|
toast.success('Roles updated successfully')
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
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?
|
// Can this user's role be changed by the current user?
|
||||||
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
|
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
|
||||||
|
|
||||||
const handleRoleChange = (newRole: Role) => {
|
// Current roles for this user (array or fallback to single role)
|
||||||
if (newRole === userRole) return
|
const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole]
|
||||||
updateUser.mutate({ id: userId, role: newRole })
|
|
||||||
|
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 () => {
|
const handleSendInvitation = async () => {
|
||||||
@@ -144,22 +156,20 @@ export function UserActions({ userId, userEmail, userStatus, userRole, currentUs
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{canChangeRole && (
|
{canChangeRole && (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger disabled={updateUser.isPending}>
|
<DropdownMenuSubTrigger disabled={updateRoles.isPending}>
|
||||||
<Shield className="mr-2 h-4 w-4" />
|
<Shield className="mr-2 h-4 w-4" />
|
||||||
{updateUser.isPending ? 'Updating...' : 'Change Role'}
|
{updateRoles.isPending ? 'Updating...' : 'Roles'}
|
||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent>
|
<DropdownMenuSubContent>
|
||||||
{getAvailableRoles().map((role) => (
|
{getAvailableRoles().map((role) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuCheckboxItem
|
||||||
key={role}
|
key={role}
|
||||||
onClick={() => handleRoleChange(role)}
|
checked={currentRoles.includes(role)}
|
||||||
disabled={role === userRole}
|
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]}
|
{ROLE_LABELS[role]}
|
||||||
</span>
|
</DropdownMenuCheckboxItem>
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
))}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
@@ -214,6 +224,7 @@ interface UserMobileActionsProps {
|
|||||||
userEmail: string
|
userEmail: string
|
||||||
userStatus: string
|
userStatus: string
|
||||||
userRole: Role
|
userRole: Role
|
||||||
|
userRoles?: Role[]
|
||||||
currentUserRole?: Role
|
currentUserRole?: Role
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,23 +233,25 @@ export function UserMobileActions({
|
|||||||
userEmail,
|
userEmail,
|
||||||
userStatus,
|
userStatus,
|
||||||
userRole,
|
userRole,
|
||||||
|
userRoles,
|
||||||
currentUserRole,
|
currentUserRole,
|
||||||
}: UserMobileActionsProps) {
|
}: UserMobileActionsProps) {
|
||||||
const [isSending, setIsSending] = useState(false)
|
const [isSending, setIsSending] = useState(false)
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
const sendInvitation = trpc.user.sendInvitation.useMutation()
|
||||||
const updateUser = trpc.user.update.useMutation({
|
const updateRoles = trpc.user.updateRoles.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.user.list.invalidate()
|
utils.user.list.invalidate()
|
||||||
toast.success('Role updated successfully')
|
toast.success('Roles updated successfully')
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || 'Failed to update role')
|
toast.error(error.message || 'Failed to update roles')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
|
const isSuperAdmin = currentUserRole === 'SUPER_ADMIN'
|
||||||
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
|
const canChangeRole = isSuperAdmin || (!['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(userRole))
|
||||||
|
const currentRoles: Role[] = userRoles?.length ? userRoles : [userRole]
|
||||||
|
|
||||||
const handleSendInvitation = async () => {
|
const handleSendInvitation = async () => {
|
||||||
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
|
if (userStatus !== 'NONE' && userStatus !== 'INVITED') {
|
||||||
@@ -283,21 +296,31 @@ export function UserMobileActions({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{canChangeRole && (
|
{canChangeRole && (
|
||||||
<select
|
<div className="flex flex-wrap gap-1.5">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{(isSuperAdmin
|
{(isSuperAdmin
|
||||||
? (['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
|
? (['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
|
||||||
: (['JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
|
: (['JURY_MEMBER', 'MENTOR', 'OBSERVER'] as Role[])
|
||||||
).map((role) => (
|
).map((role) => {
|
||||||
<option key={role} value={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]}
|
{ROLE_LABELS[role]}
|
||||||
</option>
|
</Button>
|
||||||
))}
|
)
|
||||||
</select>
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useDebouncedCallback } from 'use-debounce'
|
import { useDebouncedCallback } from 'use-debounce'
|
||||||
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||||
|
|
||||||
|
|
||||||
export function ObserverProjectsContent() {
|
export function ObserverProjectsContent() {
|
||||||
@@ -322,7 +323,15 @@ export function ObserverProjectsContent() {
|
|||||||
className="cursor-pointer hover:bg-muted/50"
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
onClick={() => router.push(`/observer/projects/${project.id}`)}
|
onClick={() => router.push(`/observer/projects/${project.id}`)}
|
||||||
>
|
>
|
||||||
<TableCell className="pl-6 max-w-[260px]">
|
<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
|
<Link
|
||||||
href={`/observer/projects/${project.id}` as Route}
|
href={`/observer/projects/${project.id}` as Route}
|
||||||
className="font-medium hover:underline truncate block"
|
className="font-medium hover:underline truncate block"
|
||||||
@@ -335,6 +344,8 @@ export function ObserverProjectsContent() {
|
|||||||
{project.teamName}
|
{project.teamName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm">
|
<TableCell className="text-sm">
|
||||||
{project.country ?? '-'}
|
{project.country ?? '-'}
|
||||||
@@ -395,6 +406,13 @@ export function ObserverProjectsContent() {
|
|||||||
<Card className="transition-colors hover:bg-muted/50">
|
<Card className="transition-colors hover:bg-muted/50">
|
||||||
<CardContent className="pt-4 space-y-2">
|
<CardContent className="pt-4 space-y-2">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<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">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-sm leading-tight truncate">
|
<p className="font-medium text-sm leading-tight truncate">
|
||||||
{project.title}
|
{project.title}
|
||||||
@@ -405,6 +423,7 @@ export function ObserverProjectsContent() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<StatusBadge status={project.observerStatus ?? project.status} />
|
<StatusBadge status={project.observerStatus ?? project.status} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -15,7 +15,19 @@ const LOCKOUT_DURATION_MS = 15 * 60 * 1000 // 15 minutes
|
|||||||
|
|
||||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
...authConfig,
|
...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: [
|
providers: [
|
||||||
// Email provider for magic links (used for first login and password reset)
|
// Email provider for magic links (used for first login and password reset)
|
||||||
EmailProvider({
|
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)
|
// Track failed attempt (don't reveal whether user exists)
|
||||||
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
|
const current = failedAttempts.get(email) || { count: 0, lockedUntil: 0 }
|
||||||
current.count++
|
current.count++
|
||||||
@@ -139,19 +151,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
}
|
}
|
||||||
failedAttempts.set(email, current)
|
failedAttempts.set(email, current)
|
||||||
|
|
||||||
// Log failed login
|
// Log failed login — real security event
|
||||||
await prisma.auditLog.create({
|
await prisma.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: null,
|
userId: null,
|
||||||
action: 'LOGIN_FAILED',
|
action: 'LOGIN_FAILED',
|
||||||
entityType: 'User',
|
entityType: 'User',
|
||||||
detailsJson: { email, reason: !user ? 'user_not_found' : user.status === 'SUSPENDED' ? 'suspended' : 'no_password' },
|
detailsJson: { email, reason: !user ? 'user_not_found' : 'suspended' },
|
||||||
},
|
},
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
// Magic-link user tried credentials form — expected, not a security event
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Verify password
|
// Verify password
|
||||||
const isValid = await verifyPassword(password, user.passwordHash)
|
const isValid = await verifyPassword(password, user.passwordHash)
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import { prisma } from '@/lib/prisma'
|
|||||||
let cachedTransporter: Transporter | null = null
|
let cachedTransporter: Transporter | null = null
|
||||||
let cachedConfigHash = ''
|
let cachedConfigHash = ''
|
||||||
let cachedFrom = ''
|
let cachedFrom = ''
|
||||||
|
let cachedAt = 0
|
||||||
|
const CACHE_TTL = 60_000 // 1 minute
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get SMTP transporter using database settings with env var fallback.
|
* Get SMTP transporter using database settings with env var fallback.
|
||||||
* Caches the transporter and rebuilds it when settings change.
|
* Caches the transporter and rebuilds it when settings change.
|
||||||
|
* Uses connection pooling for reliable bulk sends.
|
||||||
*/
|
*/
|
||||||
async function getTransporter(): Promise<{ transporter: Transporter; from: string }> {
|
async function getTransporter(): Promise<{ transporter: Transporter; from: string }> {
|
||||||
|
// Fast path: return cached transporter if still fresh
|
||||||
|
if (cachedTransporter && Date.now() - cachedAt < CACHE_TTL) {
|
||||||
|
return { transporter: cachedTransporter, from: cachedFrom }
|
||||||
|
}
|
||||||
|
|
||||||
// Read DB settings
|
// Read DB settings
|
||||||
const dbSettings = await prisma.systemSettings.findMany({
|
const dbSettings = await prisma.systemSettings.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -43,22 +51,42 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
|
|||||||
// Check if config changed since last call
|
// Check if config changed since last call
|
||||||
const configHash = `${host}:${port}:${user}:${pass}:${from}`
|
const configHash = `${host}:${port}:${user}:${pass}:${from}`
|
||||||
if (cachedTransporter && configHash === cachedConfigHash) {
|
if (cachedTransporter && configHash === cachedConfigHash) {
|
||||||
|
cachedAt = Date.now()
|
||||||
return { transporter: cachedTransporter, from: cachedFrom }
|
return { transporter: cachedTransporter, from: cachedFrom }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new transporter
|
// Close old transporter if it exists (clean up pooled connections)
|
||||||
|
if (cachedTransporter) {
|
||||||
|
try { cachedTransporter.close() } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new transporter with connection pooling for reliable bulk sends
|
||||||
cachedTransporter = nodemailer.createTransport({
|
cachedTransporter = nodemailer.createTransport({
|
||||||
host,
|
host,
|
||||||
port: parseInt(port),
|
port: parseInt(port),
|
||||||
secure: port === '465',
|
secure: port === '465',
|
||||||
auth: { user, pass },
|
auth: { user, pass },
|
||||||
})
|
pool: true,
|
||||||
|
maxConnections: 5,
|
||||||
|
maxMessages: 10,
|
||||||
|
socketTimeout: 30_000,
|
||||||
|
connectionTimeout: 15_000,
|
||||||
|
} as nodemailer.TransportOptions)
|
||||||
cachedConfigHash = configHash
|
cachedConfigHash = configHash
|
||||||
cachedFrom = from
|
cachedFrom = from
|
||||||
|
cachedAt = Date.now()
|
||||||
|
|
||||||
return { transporter: cachedTransporter, from: cachedFrom }
|
return { transporter: cachedTransporter, from: cachedFrom }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay helper for throttling bulk email sends.
|
||||||
|
* Prevents overwhelming the SMTP server (Poste.io).
|
||||||
|
*/
|
||||||
|
export function emailDelay(ms = 150): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
// Legacy references for backward compat — default sender from env
|
// Legacy references for backward compat — default sender from env
|
||||||
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
|
const defaultFrom = process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
|
||||||
|
|
||||||
@@ -1688,9 +1716,34 @@ export function getAdvancementNotificationTemplate(
|
|||||||
toRoundName: string,
|
toRoundName: string,
|
||||||
customMessage?: string,
|
customMessage?: string,
|
||||||
accountUrl?: string,
|
accountUrl?: string,
|
||||||
|
fullCustomBody?: boolean,
|
||||||
): EmailTemplate {
|
): EmailTemplate {
|
||||||
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
const greeting = name ? `Congratulations ${name}!` : 'Congratulations!'
|
||||||
|
|
||||||
|
const escapedMessage = customMessage
|
||||||
|
? customMessage
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/\n/g, '<br>')
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Full custom body mode: only the custom message inside the branded wrapper
|
||||||
|
if (fullCustomBody && escapedMessage) {
|
||||||
|
const content = `
|
||||||
|
${sectionTitle(greeting)}
|
||||||
|
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
||||||
|
${accountUrl
|
||||||
|
? ctaButton(accountUrl, 'Create Your Account')
|
||||||
|
: ctaButton('/applicant', 'View Your Dashboard')}
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
subject: `Your project has advanced: "${projectName}"`,
|
||||||
|
html: getEmailWrapper(content),
|
||||||
|
text: `${greeting}\n\n${customMessage}\n\n${accountUrl ? `Create your account: ${getBaseUrl()}${accountUrl}` : `Visit your dashboard: ${getBaseUrl()}/applicant`}\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const celebrationBanner = `
|
const celebrationBanner = `
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1702,14 +1755,6 @@ export function getAdvancementNotificationTemplate(
|
|||||||
</table>
|
</table>
|
||||||
`
|
`
|
||||||
|
|
||||||
const escapedMessage = customMessage
|
|
||||||
? customMessage
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\n/g, '<br>')
|
|
||||||
: null
|
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${celebrationBanner}
|
${celebrationBanner}
|
||||||
@@ -1757,7 +1802,8 @@ export function getRejectionNotificationTemplate(
|
|||||||
name: string,
|
name: string,
|
||||||
projectName: string,
|
projectName: string,
|
||||||
roundName: string,
|
roundName: string,
|
||||||
customMessage?: string
|
customMessage?: string,
|
||||||
|
fullCustomBody?: boolean,
|
||||||
): EmailTemplate {
|
): EmailTemplate {
|
||||||
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
const greeting = name ? `Dear ${name},` : 'Dear Applicant,'
|
||||||
|
|
||||||
@@ -1769,6 +1815,22 @@ export function getRejectionNotificationTemplate(
|
|||||||
.replace(/\n/g, '<br>')
|
.replace(/\n/g, '<br>')
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
// Full custom body mode: only the custom message inside the branded wrapper
|
||||||
|
if (fullCustomBody && escapedMessage) {
|
||||||
|
const content = `
|
||||||
|
${sectionTitle(greeting)}
|
||||||
|
<div style="color: ${BRAND.textDark}; font-size: 15px; line-height: 1.7; margin: 20px 0;">${escapedMessage}</div>
|
||||||
|
<p style="color: ${BRAND.textMuted}; margin: 24px 0 0 0; font-size: 14px; text-align: center;">
|
||||||
|
Thank you for being part of the Monaco Ocean Protection Challenge community.
|
||||||
|
</p>
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
subject: `Update on your application: "${projectName}"`,
|
||||||
|
html: getEmailWrapper(content),
|
||||||
|
text: `${greeting}\n\n${customMessage}\n\nThank you for being part of the Monaco Ocean Protection Challenge community.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`Thank you for your participation in <strong>${roundName}</strong> with your project <strong>"${projectName}"</strong>.`)}
|
${paragraph(`Thank you for your participation in <strong>${roundName}</strong> with your project <strong>"${projectName}"</strong>.`)}
|
||||||
@@ -2055,13 +2117,15 @@ export const NOTIFICATION_EMAIL_TEMPLATES: Record<string, TemplateGenerator> = {
|
|||||||
(ctx.metadata?.toRoundName as string) || 'next round',
|
(ctx.metadata?.toRoundName as string) || 'next round',
|
||||||
ctx.metadata?.customMessage as string | undefined,
|
ctx.metadata?.customMessage as string | undefined,
|
||||||
ctx.metadata?.accountUrl as string | undefined,
|
ctx.metadata?.accountUrl as string | undefined,
|
||||||
|
ctx.metadata?.fullCustomBody as boolean | undefined,
|
||||||
),
|
),
|
||||||
REJECTION_NOTIFICATION: (ctx) =>
|
REJECTION_NOTIFICATION: (ctx) =>
|
||||||
getRejectionNotificationTemplate(
|
getRejectionNotificationTemplate(
|
||||||
ctx.name || '',
|
ctx.name || '',
|
||||||
(ctx.metadata?.projectName as string) || 'Your Project',
|
(ctx.metadata?.projectName as string) || 'Your Project',
|
||||||
(ctx.metadata?.roundName as string) || 'this round',
|
(ctx.metadata?.roundName as string) || 'this round',
|
||||||
ctx.metadata?.customMessage as string | undefined
|
ctx.metadata?.customMessage as string | undefined,
|
||||||
|
ctx.metadata?.fullCustomBody as boolean | undefined,
|
||||||
),
|
),
|
||||||
|
|
||||||
AWARD_SELECTION_NOTIFICATION: (ctx) =>
|
AWARD_SELECTION_NOTIFICATION: (ctx) =>
|
||||||
|
|||||||
@@ -126,6 +126,14 @@ export function getContentType(fileName: string): string {
|
|||||||
* Validate image file type
|
* Validate image file type
|
||||||
*/
|
*/
|
||||||
export function isValidImageType(contentType: string): boolean {
|
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)
|
return validTypes.includes(contentType)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
|||||||
import { router, observerProcedure } from '../trpc'
|
import { router, observerProcedure } from '../trpc'
|
||||||
import { normalizeCountryToCode } from '@/lib/countries'
|
import { normalizeCountryToCode } from '@/lib/countries'
|
||||||
import { getUserAvatarUrl } from '../utils/avatar-url'
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
||||||
|
import { getProjectLogoUrl } from '../utils/project-logo-url'
|
||||||
import { aggregateVotes } from '../services/deliberation'
|
import { aggregateVotes } from '../services/deliberation'
|
||||||
|
|
||||||
const editionOrRoundInput = z.object({
|
const editionOrRoundInput = z.object({
|
||||||
@@ -1020,6 +1021,8 @@ export const analyticsRouter = router({
|
|||||||
teamName: true,
|
teamName: true,
|
||||||
status: true,
|
status: true,
|
||||||
country: true,
|
country: true,
|
||||||
|
logoKey: true,
|
||||||
|
logoProvider: true,
|
||||||
assignments: {
|
assignments: {
|
||||||
select: {
|
select: {
|
||||||
roundId: true,
|
roundId: true,
|
||||||
@@ -1048,7 +1051,7 @@ export const analyticsRouter = router({
|
|||||||
ctx.prisma.project.count({ where }),
|
ctx.prisma.project.count({ where }),
|
||||||
])
|
])
|
||||||
|
|
||||||
const mapped = projects.map((p) => {
|
const mapped = await Promise.all(projects.map(async (p) => {
|
||||||
const submitted = p.assignments
|
const submitted = p.assignments
|
||||||
.map((a) => a.evaluation)
|
.map((a) => a.evaluation)
|
||||||
.filter((e) => e?.status === 'SUBMITTED')
|
.filter((e) => e?.status === 'SUBMITTED')
|
||||||
@@ -1080,6 +1083,8 @@ export const analyticsRouter = router({
|
|||||||
else if (drafts.length > 0) observerStatus = 'UNDER_REVIEW'
|
else if (drafts.length > 0) observerStatus = 'UNDER_REVIEW'
|
||||||
else observerStatus = 'NOT_REVIEWED'
|
else observerStatus = 'NOT_REVIEWED'
|
||||||
|
|
||||||
|
const logoUrl = await getProjectLogoUrl(p.logoKey, p.logoProvider)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
title: p.title,
|
title: p.title,
|
||||||
@@ -1087,12 +1092,13 @@ export const analyticsRouter = router({
|
|||||||
status: p.status,
|
status: p.status,
|
||||||
observerStatus,
|
observerStatus,
|
||||||
country: p.country,
|
country: p.country,
|
||||||
|
logoUrl,
|
||||||
roundId: furthestRoundState?.round?.id ?? roundAssignment?.round?.id ?? '',
|
roundId: furthestRoundState?.round?.id ?? roundAssignment?.round?.id ?? '',
|
||||||
roundName: furthestRoundState?.round?.name ?? roundAssignment?.round?.name ?? '',
|
roundName: furthestRoundState?.round?.name ?? roundAssignment?.round?.name ?? '',
|
||||||
averageScore,
|
averageScore,
|
||||||
evaluationCount: submitted.length,
|
evaluationCount: submitted.length,
|
||||||
}
|
}
|
||||||
})
|
}))
|
||||||
|
|
||||||
// Filter by observer-derived status in JS
|
// Filter by observer-derived status in JS
|
||||||
const observerStatusFilter = input.status && OBSERVER_DERIVED_STATUSES.includes(input.status)
|
const observerStatusFilter = input.status && OBSERVER_DERIVED_STATUSES.includes(input.status)
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ function generateInviteToken(): string {
|
|||||||
return crypto.randomBytes(32).toString('hex')
|
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({
|
export const applicantRouter = router({
|
||||||
/**
|
/**
|
||||||
* Get submission info for an applicant (by round slug)
|
* 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 uploading against a requirement, validate mime type and size
|
||||||
if (input.requirementId) {
|
if (input.requirementId) {
|
||||||
const requirement = await ctx.prisma.fileRequirement.findUnique({
|
const requirement = await ctx.prisma.fileRequirement.findUnique({
|
||||||
@@ -303,21 +318,29 @@ export const applicantRouter = router({
|
|||||||
|
|
||||||
let isLate = false
|
let isLate = false
|
||||||
|
|
||||||
// Can't upload if already submitted
|
// Can't upload if already submitted — but only for initial application edits.
|
||||||
if (project.submittedAt && !isLate) {
|
// 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({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'Cannot modify a submitted project',
|
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
|
let roundName: string | undefined
|
||||||
if (input.roundId) {
|
if (input.roundId) {
|
||||||
const round = await ctx.prisma.round.findUnique({
|
const round = await ctx.prisma.round.findUnique({
|
||||||
where: { id: input.roundId },
|
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
|
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
|
const { projectId, roundId, isLate, requirementId, ...fileData } = input
|
||||||
|
|
||||||
// Delete existing file: by requirementId if provided, otherwise by fileType
|
// 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
|
// Block rejected projects
|
||||||
if (file.project.submittedAt) {
|
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({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'Cannot modify a submitted project',
|
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({
|
await ctx.prisma.projectFile.delete({
|
||||||
where: { id: input.fileId },
|
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
|
// Check if already a team member
|
||||||
const existingMember = await ctx.prisma.teamMember.findFirst({
|
const existingMember = await ctx.prisma.teamMember.findFirst({
|
||||||
where: {
|
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
|
// Can't remove the original submitter
|
||||||
if (project.submittedByUserId === input.userId) {
|
if (project.submittedByUserId === input.userId) {
|
||||||
throw new TRPCError({
|
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
|
const hasWonAward = project.wonAwards.length > 0
|
||||||
|
|
||||||
// Build timeline
|
// Build timeline
|
||||||
@@ -1382,9 +1439,10 @@ export const applicantRouter = router({
|
|||||||
isTeamLead,
|
isTeamLead,
|
||||||
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
|
userRole: userMembership?.role || (project.submittedByUserId === ctx.user.id ? 'LEAD' : null),
|
||||||
},
|
},
|
||||||
openRounds,
|
openRounds: isRejected ? [] : openRounds,
|
||||||
timeline,
|
timeline,
|
||||||
currentStatus,
|
currentStatus,
|
||||||
|
isRejected,
|
||||||
hasPassedIntake: !!passedIntake,
|
hasPassedIntake: !!passedIntake,
|
||||||
isIntakeOpen: !!activeIntakeRound,
|
isIntakeOpen: !!activeIntakeRound,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
@@ -1442,9 +1500,12 @@ export const applicantRouter = router({
|
|||||||
select: { configJson: true },
|
select: { configJson: true },
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
const navProjectRejected = await isProjectRejected(ctx.prisma, project.id)
|
||||||
hasEvaluationRounds = closedEvalRounds.some((r) => {
|
hasEvaluationRounds = closedEvalRounds.some((r) => {
|
||||||
const parsed = EvaluationConfigSchema.safeParse(r.configJson)
|
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++) {
|
for (let i = 0; i < evalRounds.length; i++) {
|
||||||
const round = evalRounds[i]
|
const round = evalRounds[i]
|
||||||
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
|
const parsed = EvaluationConfigSchema.safeParse(round.configJson)
|
||||||
if (!parsed.success || !parsed.data.applicantVisibility.enabled) continue
|
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
|
const vis = parsed.data.applicantVisibility
|
||||||
|
|
||||||
// Get evaluations via assignments — NEVER select userId or user relation
|
// 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' })
|
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(
|
return getImageUploadUrl(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
input.fileName,
|
input.fileName,
|
||||||
|
|||||||
@@ -4,13 +4,17 @@ import { TRPCError } from '@trpc/server'
|
|||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc'
|
import { router, protectedProcedure, adminProcedure, userHasRole } from '../trpc'
|
||||||
import { getUserAvatarUrl } from '../utils/avatar-url'
|
import { getUserAvatarUrl } from '../utils/avatar-url'
|
||||||
|
import { attachProjectLogoUrls } from '../utils/project-logo-url'
|
||||||
import {
|
import {
|
||||||
notifyProjectTeam,
|
notifyProjectTeam,
|
||||||
NotificationTypes,
|
NotificationTypes,
|
||||||
} from '../services/in-app-notification'
|
} from '../services/in-app-notification'
|
||||||
import { normalizeCountryToCode } from '@/lib/countries'
|
import { normalizeCountryToCode } from '@/lib/countries'
|
||||||
import { logAudit } from '../utils/audit'
|
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 INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||||
const STATUSES_WITH_TEAM_NOTIFICATIONS = ['SEMIFINALIST', 'FINALIST', 'REJECTED'] as const
|
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({
|
ctx.prisma.project.findMany({
|
||||||
where,
|
where,
|
||||||
skip,
|
skip,
|
||||||
@@ -149,24 +153,33 @@ export const projectRouter = router({
|
|||||||
include: {
|
include: {
|
||||||
program: { select: { id: true, name: true, year: true } },
|
program: { select: { id: true, name: true, year: true } },
|
||||||
_count: { select: { assignments: true, files: 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.count({ where }),
|
||||||
ctx.prisma.project.groupBy({
|
ctx.prisma.projectRoundState.groupBy({
|
||||||
by: ['status'],
|
by: ['state'],
|
||||||
where,
|
where: where.programId ? { project: { programId: where.programId as string } } : {},
|
||||||
_count: true,
|
_count: true,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
// Build status counts from groupBy (across all pages)
|
// Build round-state counts
|
||||||
const statusCounts: Record<string, number> = {}
|
const statusCounts: Record<string, number> = {}
|
||||||
for (const g of statusGroups) {
|
for (const g of roundStateCounts) {
|
||||||
statusCounts[g.status] = g._count
|
statusCounts[g.state] = g._count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectsWithLogos = await attachProjectLogoUrls(projects)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projects,
|
projects: projectsWithLogos,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
perPage,
|
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({
|
ctx.prisma.projectTag.findMany({
|
||||||
@@ -1389,4 +1409,761 @@ export const projectRouter = router({
|
|||||||
|
|
||||||
return project
|
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 }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import { createBulkNotifications } from '../services/in-app-notification'
|
|||||||
import {
|
import {
|
||||||
getAdvancementNotificationTemplate,
|
getAdvancementNotificationTemplate,
|
||||||
getRejectionNotificationTemplate,
|
getRejectionNotificationTemplate,
|
||||||
sendStyledNotificationEmail,
|
|
||||||
sendInvitationEmail,
|
sendInvitationEmail,
|
||||||
getBaseUrl,
|
getBaseUrl,
|
||||||
} from '@/lib/email'
|
} 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 { generateInviteToken, getInviteExpiryHours, getInviteExpiryMs } from '@/server/utils/invite'
|
||||||
import {
|
import {
|
||||||
openWindow,
|
openWindow,
|
||||||
@@ -812,10 +813,11 @@ export const roundRouter = router({
|
|||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
targetRoundId: z.string().optional(),
|
targetRoundId: z.string().optional(),
|
||||||
customMessage: z.string().optional(),
|
customMessage: z.string().optional(),
|
||||||
|
fullCustomBody: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { roundId, targetRoundId, customMessage } = input
|
const { roundId, targetRoundId, customMessage, fullCustomBody } = input
|
||||||
|
|
||||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
@@ -865,7 +867,9 @@ export const roundRouter = router({
|
|||||||
'Your Project',
|
'Your Project',
|
||||||
currentRound.name,
|
currentRound.name,
|
||||||
toRoundName,
|
toRoundName,
|
||||||
customMessage || undefined
|
customMessage || undefined,
|
||||||
|
undefined,
|
||||||
|
fullCustomBody,
|
||||||
)
|
)
|
||||||
|
|
||||||
return { html: template.html, subject: template.subject, recipientCount }
|
return { html: template.html, subject: template.subject, recipientCount }
|
||||||
@@ -877,11 +881,12 @@ export const roundRouter = router({
|
|||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
targetRoundId: z.string().optional(),
|
targetRoundId: z.string().optional(),
|
||||||
customMessage: z.string().optional(),
|
customMessage: z.string().optional(),
|
||||||
|
fullCustomBody: z.boolean().default(false),
|
||||||
projectIds: z.array(z.string()).optional(),
|
projectIds: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { roundId, targetRoundId, customMessage } = input
|
const { roundId, targetRoundId, customMessage, fullCustomBody } = input
|
||||||
|
|
||||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
@@ -922,29 +927,27 @@ export const roundRouter = router({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let sent = 0
|
|
||||||
let failed = 0
|
|
||||||
const allUserIds = new Set<string>()
|
const allUserIds = new Set<string>()
|
||||||
|
const items: NotificationItem[] = []
|
||||||
|
|
||||||
for (const project of projects) {
|
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) {
|
for (const tm of project.teamMembers) {
|
||||||
if (tm.user.email) {
|
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)
|
allUserIds.add(tm.user.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (recipients.size === 0 && project.submittedByEmail) {
|
if (recipients.size === 0 && project.submittedByEmail) {
|
||||||
recipients.set(project.submittedByEmail, null)
|
recipients.set(project.submittedByEmail, { name: null, userId: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [email, name] of recipients) {
|
for (const [email, { name, userId }] of recipients) {
|
||||||
try {
|
items.push({
|
||||||
await sendStyledNotificationEmail(
|
|
||||||
email,
|
email,
|
||||||
name || '',
|
name: name || '',
|
||||||
'ADVANCEMENT_NOTIFICATION',
|
type: 'ADVANCEMENT_NOTIFICATION',
|
||||||
{
|
context: {
|
||||||
title: 'Your project has advanced!',
|
title: 'Your project has advanced!',
|
||||||
message: '',
|
message: '',
|
||||||
linkUrl: '/applicant',
|
linkUrl: '/applicant',
|
||||||
@@ -953,17 +956,18 @@ export const roundRouter = router({
|
|||||||
fromRoundName: currentRound.name,
|
fromRoundName: currentRound.name,
|
||||||
toRoundName,
|
toRoundName,
|
||||||
customMessage: customMessage || undefined,
|
customMessage: customMessage || undefined,
|
||||||
|
fullCustomBody,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
projectId: project.id,
|
||||||
sent++
|
userId: userId || undefined,
|
||||||
} catch (err) {
|
roundId,
|
||||||
console.error(`[sendAdvancementNotifications] Failed for ${email}:`, err)
|
})
|
||||||
failed++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await sendBatchNotifications(items)
|
||||||
|
|
||||||
// Create in-app notifications
|
// Create in-app notifications
|
||||||
if (allUserIds.size > 0) {
|
if (allUserIds.size > 0) {
|
||||||
void createBulkNotifications({
|
void createBulkNotifications({
|
||||||
@@ -985,12 +989,12 @@ export const roundRouter = router({
|
|||||||
action: 'SEND_ADVANCEMENT_NOTIFICATIONS',
|
action: 'SEND_ADVANCEMENT_NOTIFICATIONS',
|
||||||
entityType: 'Round',
|
entityType: 'Round',
|
||||||
entityId: roundId,
|
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,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { sent, failed }
|
return { sent: result.sent, failed: result.failed }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
previewRejectionEmail: adminProcedure
|
previewRejectionEmail: adminProcedure
|
||||||
@@ -998,22 +1002,36 @@ export const roundRouter = router({
|
|||||||
z.object({
|
z.object({
|
||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
customMessage: z.string().optional(),
|
customMessage: z.string().optional(),
|
||||||
|
fullCustomBody: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { roundId, customMessage } = input
|
const { roundId, customMessage, fullCustomBody } = input
|
||||||
|
|
||||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
select: { name: true },
|
select: { name: true, roundType: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Count recipients: team members of REJECTED projects
|
// 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({
|
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
where: { roundId, state: 'REJECTED' },
|
where: { roundId, state: 'REJECTED' },
|
||||||
select: { projectId: true },
|
select: { projectId: true },
|
||||||
})
|
})
|
||||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
projectIds = projectStates.map((ps) => ps.projectId)
|
||||||
|
}
|
||||||
|
|
||||||
let recipientCount = 0
|
let recipientCount = 0
|
||||||
if (projectIds.length > 0) {
|
if (projectIds.length > 0) {
|
||||||
@@ -1039,7 +1057,8 @@ export const roundRouter = router({
|
|||||||
'Team Member',
|
'Team Member',
|
||||||
'Your Project',
|
'Your Project',
|
||||||
round.name,
|
round.name,
|
||||||
customMessage || undefined
|
customMessage || undefined,
|
||||||
|
fullCustomBody,
|
||||||
)
|
)
|
||||||
|
|
||||||
return { html: template.html, subject: template.subject, recipientCount }
|
return { html: template.html, subject: template.subject, recipientCount }
|
||||||
@@ -1050,21 +1069,36 @@ export const roundRouter = router({
|
|||||||
z.object({
|
z.object({
|
||||||
roundId: z.string(),
|
roundId: z.string(),
|
||||||
customMessage: z.string().optional(),
|
customMessage: z.string().optional(),
|
||||||
|
fullCustomBody: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const { roundId, customMessage } = input
|
const { roundId, customMessage, fullCustomBody } = input
|
||||||
|
|
||||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||||
where: { id: roundId },
|
where: { id: roundId },
|
||||||
select: { name: true },
|
select: { name: true, roundType: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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({
|
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
where: { roundId, state: 'REJECTED' },
|
where: { roundId, state: 'REJECTED' },
|
||||||
select: { projectId: true },
|
select: { projectId: true },
|
||||||
})
|
})
|
||||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
projectIds = projectStates.map((ps) => ps.projectId)
|
||||||
|
}
|
||||||
|
|
||||||
if (projectIds.length === 0) {
|
if (projectIds.length === 0) {
|
||||||
return { sent: 0, failed: 0 }
|
return { sent: 0, failed: 0 }
|
||||||
@@ -1082,29 +1116,27 @@ export const roundRouter = router({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let sent = 0
|
|
||||||
let failed = 0
|
|
||||||
const allUserIds = new Set<string>()
|
const allUserIds = new Set<string>()
|
||||||
|
const items: NotificationItem[] = []
|
||||||
|
|
||||||
for (const project of projects) {
|
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) {
|
for (const tm of project.teamMembers) {
|
||||||
if (tm.user.email) {
|
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)
|
allUserIds.add(tm.user.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (recipients.size === 0 && project.submittedByEmail) {
|
if (recipients.size === 0 && project.submittedByEmail) {
|
||||||
recipients.set(project.submittedByEmail, null)
|
recipients.set(project.submittedByEmail, { name: null, userId: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [email, name] of recipients) {
|
for (const [email, { name, userId }] of recipients) {
|
||||||
try {
|
items.push({
|
||||||
await sendStyledNotificationEmail(
|
|
||||||
email,
|
email,
|
||||||
name || '',
|
name: name || '',
|
||||||
'REJECTION_NOTIFICATION',
|
type: 'REJECTION_NOTIFICATION',
|
||||||
{
|
context: {
|
||||||
title: 'Update on your application',
|
title: 'Update on your application',
|
||||||
message: '',
|
message: '',
|
||||||
linkUrl: '/applicant',
|
linkUrl: '/applicant',
|
||||||
@@ -1112,17 +1144,18 @@ export const roundRouter = router({
|
|||||||
projectName: project.title,
|
projectName: project.title,
|
||||||
roundName: round.name,
|
roundName: round.name,
|
||||||
customMessage: customMessage || undefined,
|
customMessage: customMessage || undefined,
|
||||||
|
fullCustomBody,
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
)
|
projectId: project.id,
|
||||||
sent++
|
userId: userId || undefined,
|
||||||
} catch (err) {
|
roundId,
|
||||||
console.error(`[sendRejectionNotifications] Failed for ${email}:`, err)
|
})
|
||||||
failed++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await sendBatchNotifications(items)
|
||||||
|
|
||||||
// In-app notifications
|
// In-app notifications
|
||||||
if (allUserIds.size > 0) {
|
if (allUserIds.size > 0) {
|
||||||
void createBulkNotifications({
|
void createBulkNotifications({
|
||||||
@@ -1142,12 +1175,12 @@ export const roundRouter = router({
|
|||||||
action: 'SEND_REJECTION_NOTIFICATIONS',
|
action: 'SEND_REJECTION_NOTIFICATIONS',
|
||||||
entityType: 'Round',
|
entityType: 'Round',
|
||||||
entityId: roundId,
|
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,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { sent, failed }
|
return { sent: result.sent, failed: result.failed }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getBulkInvitePreview: adminProcedure
|
getBulkInvitePreview: adminProcedure
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import { Prisma } from '@prisma/client'
|
|||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
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 { 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'
|
import type { PrismaClient } from '@prisma/client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1270,8 +1272,18 @@ export const specialAwardRouter = router({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Get eligible projects that haven't been notified yet
|
// 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({
|
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: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
projectId: true,
|
projectId: true,
|
||||||
@@ -1324,12 +1336,12 @@ export const specialAwardRouter = router({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send emails
|
// Build notification items — track which eligibility each email belongs to
|
||||||
let emailsSent = 0
|
const items: NotificationItem[] = []
|
||||||
let emailsFailed = 0
|
const eligibilityEmailMap = new Map<string, Set<string>>() // eligibilityId → Set<email>
|
||||||
|
|
||||||
for (const e of eligibilities) {
|
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)
|
if (e.project.submittedBy) recipients.push(e.project.submittedBy)
|
||||||
for (const tm of e.project.teamMembers) {
|
for (const tm of e.project.teamMembers) {
|
||||||
if (!recipients.some((r) => r.id === tm.user.id)) {
|
if (!recipients.some((r) => r.id === tm.user.id)) {
|
||||||
@@ -1337,16 +1349,17 @@ export const specialAwardRouter = router({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emails = new Set<string>()
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
const token = tokenMap.get(recipient.id)
|
const token = tokenMap.get(recipient.id)
|
||||||
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
||||||
|
emails.add(recipient.email)
|
||||||
|
|
||||||
try {
|
items.push({
|
||||||
await sendStyledNotificationEmail(
|
email: recipient.email,
|
||||||
recipient.email,
|
name: recipient.name || '',
|
||||||
recipient.name || '',
|
type: 'AWARD_SELECTION_NOTIFICATION',
|
||||||
'AWARD_SELECTION_NOTIFICATION',
|
context: {
|
||||||
{
|
|
||||||
title: `Under consideration for ${award.name}`,
|
title: `Under consideration for ${award.name}`,
|
||||||
message: input.customMessage || '',
|
message: input.customMessage || '',
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -1356,20 +1369,26 @@ export const specialAwardRouter = router({
|
|||||||
accountUrl,
|
accountUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
projectId: e.projectId,
|
||||||
emailsSent++
|
userId: recipient.id,
|
||||||
} catch (err) {
|
})
|
||||||
console.error(`[award-notify] Failed to email ${recipient.email}:`, err)
|
|
||||||
emailsFailed++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
eligibilityEmailMap.set(e.id, emails)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stamp notifiedAt on all processed eligibilities to prevent re-notification
|
const result = await sendBatchNotifications(items)
|
||||||
const notifiedIds = eligibilities.map((e) => e.id)
|
|
||||||
if (notifiedIds.length > 0) {
|
// 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({
|
await ctx.prisma.awardEligibility.updateMany({
|
||||||
where: { id: { in: notifiedIds } },
|
where: { id: { in: successfulEligibilityIds } },
|
||||||
data: { notifiedAt: new Date() },
|
data: { notifiedAt: new Date() },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1383,14 +1402,15 @@ export const specialAwardRouter = router({
|
|||||||
detailsJson: {
|
detailsJson: {
|
||||||
action: 'NOTIFY_ELIGIBLE_PROJECTS',
|
action: 'NOTIFY_ELIGIBLE_PROJECTS',
|
||||||
eligibleCount: eligibilities.length,
|
eligibleCount: eligibilities.length,
|
||||||
emailsSent,
|
emailsSent: result.sent,
|
||||||
emailsFailed,
|
emailsFailed: result.failed,
|
||||||
|
failedRecipients: result.errors.length > 0 ? result.errors.map((e) => e.email) : undefined,
|
||||||
},
|
},
|
||||||
ipAddress: ctx.ip,
|
ipAddress: ctx.ip,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
})
|
})
|
||||||
|
|
||||||
return { notified: eligibilities.length, emailsSent, emailsFailed }
|
return { notified: successfulEligibilityIds.length, emailsSent: result.sent, emailsFailed: result.failed }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
97
src/server/services/notification-sender.ts
Normal file
97
src/server/services/notification-sender.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { sendStyledNotificationEmail, emailDelay } from '@/lib/email'
|
||||||
|
import type { NotificationEmailContext } from '@/lib/email'
|
||||||
|
|
||||||
|
export type NotificationItem = {
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
type: string // ADVANCEMENT_NOTIFICATION, REJECTION_NOTIFICATION, etc.
|
||||||
|
context: NotificationEmailContext
|
||||||
|
projectId?: string
|
||||||
|
userId?: string
|
||||||
|
roundId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BatchResult = {
|
||||||
|
sent: number
|
||||||
|
failed: number
|
||||||
|
batchId: string
|
||||||
|
errors: Array<{ email: string; error: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notifications in batches with throttling and per-email logging.
|
||||||
|
* Each email is logged to NotificationLog with SENT or FAILED status.
|
||||||
|
*/
|
||||||
|
export async function sendBatchNotifications(
|
||||||
|
items: NotificationItem[],
|
||||||
|
options?: { batchSize?: number; batchDelayMs?: number }
|
||||||
|
): Promise<BatchResult> {
|
||||||
|
const batchId = randomUUID()
|
||||||
|
const batchSize = options?.batchSize ?? 10
|
||||||
|
const batchDelayMs = options?.batchDelayMs ?? 500
|
||||||
|
|
||||||
|
let sent = 0
|
||||||
|
let failed = 0
|
||||||
|
const errors: Array<{ email: string; error: string }> = []
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i += batchSize) {
|
||||||
|
const chunk = items.slice(i, i + batchSize)
|
||||||
|
|
||||||
|
for (const item of chunk) {
|
||||||
|
try {
|
||||||
|
await sendStyledNotificationEmail(
|
||||||
|
item.email,
|
||||||
|
item.name,
|
||||||
|
item.type,
|
||||||
|
item.context,
|
||||||
|
)
|
||||||
|
sent++
|
||||||
|
|
||||||
|
// Log success (fire-and-forget)
|
||||||
|
prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: item.userId || null,
|
||||||
|
channel: 'EMAIL',
|
||||||
|
type: item.type,
|
||||||
|
status: 'SENT',
|
||||||
|
email: item.email,
|
||||||
|
roundId: item.roundId || null,
|
||||||
|
projectId: item.projectId || null,
|
||||||
|
batchId,
|
||||||
|
},
|
||||||
|
}).catch((err) => console.error('[notification-sender] Log write failed:', err))
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err instanceof Error ? err.message : String(err)
|
||||||
|
failed++
|
||||||
|
errors.push({ email: item.email, error: errorMsg })
|
||||||
|
console.error(`[notification-sender] Failed for ${item.email}:`, err)
|
||||||
|
|
||||||
|
// Log failure (fire-and-forget)
|
||||||
|
prisma.notificationLog.create({
|
||||||
|
data: {
|
||||||
|
userId: item.userId || null,
|
||||||
|
channel: 'EMAIL',
|
||||||
|
type: item.type,
|
||||||
|
status: 'FAILED',
|
||||||
|
email: item.email,
|
||||||
|
roundId: item.roundId || null,
|
||||||
|
projectId: item.projectId || null,
|
||||||
|
batchId,
|
||||||
|
errorMsg,
|
||||||
|
},
|
||||||
|
}).catch((logErr) => console.error('[notification-sender] Log write failed:', logErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
await emailDelay()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay between chunks to avoid overwhelming SMTP
|
||||||
|
if (i + batchSize < items.length) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, batchDelayMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent, failed, batchId, errors }
|
||||||
|
}
|
||||||
@@ -10,12 +10,11 @@
|
|||||||
import type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client'
|
import type { PrismaClient, ProjectRoundStateValue, RoundType, Prisma } from '@prisma/client'
|
||||||
import { transitionProject, isTerminalState } from './round-engine'
|
import { transitionProject, isTerminalState } from './round-engine'
|
||||||
import { logAudit } from '@/server/utils/audit'
|
import { logAudit } from '@/server/utils/audit'
|
||||||
import {
|
import { getRejectionNotificationTemplate } from '@/lib/email'
|
||||||
sendStyledNotificationEmail,
|
|
||||||
getRejectionNotificationTemplate,
|
|
||||||
} from '@/lib/email'
|
|
||||||
import { createBulkNotifications } from '../services/in-app-notification'
|
import { createBulkNotifications } from '../services/in-app-notification'
|
||||||
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
||||||
|
import { sendBatchNotifications } from './notification-sender'
|
||||||
|
import type { NotificationItem } from './notification-sender'
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -724,6 +723,7 @@ export async function confirmFinalization(
|
|||||||
|
|
||||||
const advancedUserIds = new Set<string>()
|
const advancedUserIds = new Set<string>()
|
||||||
const rejectedUserIds = new Set<string>()
|
const rejectedUserIds = new Set<string>()
|
||||||
|
const notificationItems: NotificationItem[] = []
|
||||||
|
|
||||||
for (const prs of finalizedStates) {
|
for (const prs of finalizedStates) {
|
||||||
type Recipient = { email: string; name: string | null; userId: string | null }
|
type Recipient = { email: string; name: string | null; userId: string | null }
|
||||||
@@ -748,17 +748,15 @@ export async function confirmFinalization(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
for (const recipient of recipients) {
|
||||||
try {
|
|
||||||
if (prs.state === 'PASSED') {
|
if (prs.state === 'PASSED') {
|
||||||
// Build account creation URL for passwordless users
|
|
||||||
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
|
const token = recipient.userId ? inviteTokenMap.get(recipient.userId) : undefined
|
||||||
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
const accountUrl = token ? `/accept-invite?token=${token}` : undefined
|
||||||
|
|
||||||
await sendStyledNotificationEmail(
|
notificationItems.push({
|
||||||
recipient.email,
|
email: recipient.email,
|
||||||
recipient.name || '',
|
name: recipient.name || '',
|
||||||
'ADVANCEMENT_NOTIFICATION',
|
type: 'ADVANCEMENT_NOTIFICATION',
|
||||||
{
|
context: {
|
||||||
title: 'Your project has advanced!',
|
title: 'Your project has advanced!',
|
||||||
message: '',
|
message: '',
|
||||||
linkUrl: accountUrl || '/applicant',
|
linkUrl: accountUrl || '/applicant',
|
||||||
@@ -770,13 +768,16 @@ export async function confirmFinalization(
|
|||||||
accountUrl,
|
accountUrl,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
projectId: prs.projectId,
|
||||||
|
userId: recipient.userId || undefined,
|
||||||
|
roundId: round.id,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
await sendStyledNotificationEmail(
|
notificationItems.push({
|
||||||
recipient.email,
|
email: recipient.email,
|
||||||
recipient.name || '',
|
name: recipient.name || '',
|
||||||
'REJECTION_NOTIFICATION',
|
type: 'REJECTION_NOTIFICATION',
|
||||||
{
|
context: {
|
||||||
title: `Update on your application: "${prs.project.title}"`,
|
title: `Update on your application: "${prs.project.title}"`,
|
||||||
message: '',
|
message: '',
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -785,16 +786,18 @@ export async function confirmFinalization(
|
|||||||
customMessage: options.rejectionMessage || undefined,
|
customMessage: options.rejectionMessage || undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
projectId: prs.projectId,
|
||||||
}
|
userId: recipient.userId || undefined,
|
||||||
emailsSent++
|
roundId: round.id,
|
||||||
} catch (err) {
|
})
|
||||||
console.error(`[Finalization] Email failed for ${recipient.email}:`, err)
|
|
||||||
emailsFailed++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const batchResult = await sendBatchNotifications(notificationItems)
|
||||||
|
emailsSent = batchResult.sent
|
||||||
|
emailsFailed = batchResult.failed
|
||||||
|
|
||||||
// Create in-app notifications
|
// Create in-app notifications
|
||||||
if (advancedUserIds.size > 0) {
|
if (advancedUserIds.size > 0) {
|
||||||
void createBulkNotifications({
|
void createBulkNotifications({
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export async function getImageUploadUrl(
|
|||||||
if (!isValidImageType(contentType)) {
|
if (!isValidImageType(contentType)) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP',
|
message: `Invalid image type: "${contentType}". Allowed: JPEG, PNG, GIF, WebP, SVG`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
35
src/server/utils/project-logo-url.ts
Normal file
35
src/server/utils/project-logo-url.ts
Normal 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),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -117,12 +117,14 @@ export const EvaluationConfigSchema = z.object({
|
|||||||
showGlobalScore: z.boolean().default(false),
|
showGlobalScore: z.boolean().default(false),
|
||||||
showCriterionScores: z.boolean().default(false),
|
showCriterionScores: z.boolean().default(false),
|
||||||
showFeedbackText: z.boolean().default(false),
|
showFeedbackText: z.boolean().default(false),
|
||||||
|
hideFromRejected: z.boolean().default(false),
|
||||||
})
|
})
|
||||||
.default({
|
.default({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
showGlobalScore: false,
|
showGlobalScore: false,
|
||||||
showCriterionScores: false,
|
showCriterionScores: false,
|
||||||
showFeedbackText: false,
|
showFeedbackText: false,
|
||||||
|
hideFromRejected: false,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
advancementMode: z
|
advancementMode: z
|
||||||
|
|||||||
Reference in New Issue
Block a user