From 3be6a743ed0a592098368c9386777f1b4c896b3a Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 3 Feb 2026 23:19:45 +0100 Subject: [PATCH] Add multiple admin improvements and bug fixes - Email settings: Add separate sender display name field - Rounds page: Drag-and-drop reordering with visible order numbers - Round creation: Auto-assign projects to filtering rounds, auto-activate if voting started - Round detail: Fix incorrect "voting period ended" message for draft rounds - Projects page: Add delete option with confirmation dialog - AI filtering: Add configurable batch size and parallel request settings - Filtering results: Fix duplicate criteria display - Add seed scripts for notification settings and MOPC onboarding form Co-Authored-By: Claude Opus 4.5 --- prisma/seed-mopc-onboarding.mjs | 270 ++++++++++++ prisma/seed-notification-settings.mjs | 73 ++++ src/app/(admin)/admin/projects/page.tsx | 72 ++++ .../rounds/[id]/filtering/results/page.tsx | 4 +- .../rounds/[id]/filtering/rules/page.tsx | 77 +++- src/app/(admin)/admin/rounds/[id]/page.tsx | 14 +- src/app/(admin)/admin/rounds/new/page.tsx | 2 + src/app/(admin)/admin/rounds/page.tsx | 388 ++++++++++++------ .../settings/email-settings-form.tsx | 55 ++- src/lib/email.ts | 8 +- src/server/routers/round.ts | 39 ++ src/server/services/ai-filtering.ts | 85 ++-- 12 files changed, 895 insertions(+), 192 deletions(-) create mode 100644 prisma/seed-mopc-onboarding.mjs create mode 100644 prisma/seed-notification-settings.mjs diff --git a/prisma/seed-mopc-onboarding.mjs b/prisma/seed-mopc-onboarding.mjs new file mode 100644 index 0000000..4d79cc3 --- /dev/null +++ b/prisma/seed-mopc-onboarding.mjs @@ -0,0 +1,270 @@ +/** + * Seed script for MOPC Onboarding Form (ESM version for production) + * Run with: node prisma/seed-mopc-onboarding.mjs + */ + +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +const MOPC_FORM_CONFIG = { + name: 'MOPC Application 2026', + description: 'Monaco Ocean Protection Challenge application form', + publicSlug: 'mopc-2026', + status: 'PUBLISHED', + isPublic: true, + sendConfirmationEmail: true, + sendTeamInviteEmails: true, + confirmationEmailSubject: 'Application Received - Monaco Ocean Protection Challenge', + confirmationEmailBody: `Thank you for applying to the Monaco Ocean Protection Challenge 2026! + +We have received your application and our team will review it carefully. + +If you have any questions, please don't hesitate to reach out. + +Good luck! +The MOPC Team`, + confirmationMessage: 'Thank you for your application! We have sent a confirmation email to the address you provided. Our team will review your submission and get back to you soon.', +} + +const STEPS = [ + { + name: 'category', + title: 'Competition Category', + description: 'Select your competition track', + sortOrder: 0, + isOptional: false, + fields: [ + { + name: 'competitionCategory', + label: 'Which category best describes your project?', + fieldType: 'RADIO', + specialType: 'COMPETITION_CATEGORY', + required: true, + sortOrder: 0, + width: 'full', + projectMapping: 'competitionCategory', + description: 'Choose the category that best fits your stage of development', + optionsJson: [ + { value: 'STARTUP', label: 'Startup', description: 'You have an existing company or registered business entity' }, + { value: 'BUSINESS_CONCEPT', label: 'Business Concept', description: 'You are a student, graduate, or have an idea not yet incorporated' }, + ], + }, + ], + }, + { + name: 'contact', + title: 'Contact Information', + description: 'Tell us how to reach you', + sortOrder: 1, + isOptional: false, + fields: [ + { name: 'contactName', label: 'Full Name', fieldType: 'TEXT', required: true, sortOrder: 0, width: 'half', placeholder: 'Enter your full name' }, + { name: 'contactEmail', label: 'Email Address', fieldType: 'EMAIL', required: true, sortOrder: 1, width: 'half', placeholder: 'your.email@example.com', description: 'We will use this email for all communications' }, + { name: 'contactPhone', label: 'Phone Number', fieldType: 'PHONE', required: true, sortOrder: 2, width: 'half', placeholder: '+1 (555) 123-4567' }, + { name: 'country', label: 'Country', fieldType: 'SELECT', specialType: 'COUNTRY_SELECT', required: true, sortOrder: 3, width: 'half', projectMapping: 'country' }, + { name: 'city', label: 'City', fieldType: 'TEXT', required: false, sortOrder: 4, width: 'half', placeholder: 'City name' }, + ], + }, + { + name: 'project', + title: 'Project Details', + description: 'Tell us about your ocean protection project', + sortOrder: 2, + isOptional: false, + fields: [ + { name: 'projectName', label: 'Project Name', fieldType: 'TEXT', required: true, sortOrder: 0, width: 'full', projectMapping: 'title', maxLength: 200, placeholder: 'Give your project a memorable name' }, + { name: 'teamName', label: 'Team / Company Name', fieldType: 'TEXT', required: false, sortOrder: 1, width: 'half', projectMapping: 'teamName', placeholder: 'Your team or company name' }, + { name: 'oceanIssue', label: 'Primary Ocean Issue', fieldType: 'SELECT', specialType: 'OCEAN_ISSUE', required: true, sortOrder: 2, width: 'half', projectMapping: 'oceanIssue', description: 'Select the primary ocean issue your project addresses' }, + { name: 'description', label: 'Project Description', fieldType: 'TEXTAREA', required: true, sortOrder: 3, width: 'full', projectMapping: 'description', minLength: 50, maxLength: 2000, placeholder: 'Describe your project, its goals, and how it will help protect the ocean...', description: 'Provide a clear description of your project (50-2000 characters)' }, + { name: 'websiteUrl', label: 'Website URL', fieldType: 'URL', required: false, sortOrder: 4, width: 'half', projectMapping: 'websiteUrl', placeholder: 'https://yourproject.com' }, + ], + }, + { + name: 'team', + title: 'Team Members', + description: 'Add your team members (they will receive email invitations)', + sortOrder: 3, + isOptional: true, + fields: [ + { name: 'teamMembers', label: 'Team Members', fieldType: 'TEXT', specialType: 'TEAM_MEMBERS', required: false, sortOrder: 0, width: 'full', description: 'Add up to 5 team members. They will receive an invitation email to join your application.' }, + ], + }, + { + name: 'additional', + title: 'Additional Details', + description: 'A few more questions about your project', + sortOrder: 4, + isOptional: false, + fields: [ + { name: 'institution', label: 'University / School', fieldType: 'TEXT', required: false, sortOrder: 0, width: 'half', projectMapping: 'institution', placeholder: 'Name of your institution', conditionJson: { field: 'competitionCategory', operator: 'equals', value: 'BUSINESS_CONCEPT' } }, + { name: 'startupCreatedDate', label: 'Startup Founded Date', fieldType: 'DATE', required: false, sortOrder: 1, width: 'half', description: 'When was your company founded?', conditionJson: { field: 'competitionCategory', operator: 'equals', value: 'STARTUP' } }, + { name: 'wantsMentorship', label: 'I am interested in receiving mentorship', fieldType: 'CHECKBOX', required: false, sortOrder: 2, width: 'full', projectMapping: 'wantsMentorship', description: 'Check this box if you would like to be paired with an expert mentor' }, + { name: 'referralSource', label: 'How did you hear about MOPC?', fieldType: 'SELECT', required: false, sortOrder: 3, width: 'half', optionsJson: [ + { value: 'social_media', label: 'Social Media' }, + { value: 'search_engine', label: 'Search Engine' }, + { value: 'word_of_mouth', label: 'Word of Mouth' }, + { value: 'university', label: 'University / School' }, + { value: 'partner', label: 'Partner Organization' }, + { value: 'media', label: 'News / Media' }, + { value: 'event', label: 'Event / Conference' }, + { value: 'other', label: 'Other' }, + ]}, + ], + }, + { + name: 'review', + title: 'Review & Submit', + description: 'Review your application and accept the terms', + sortOrder: 5, + isOptional: false, + fields: [ + { name: 'instructions', label: 'Review Instructions', fieldType: 'INSTRUCTIONS', required: false, sortOrder: 0, width: 'full', description: 'Please review all the information you have provided. Once submitted, you will not be able to make changes.' }, + { name: 'gdprConsent', label: 'I consent to the processing of my personal data in accordance with the GDPR and the MOPC Privacy Policy', fieldType: 'CHECKBOX', specialType: 'GDPR_CONSENT', required: true, sortOrder: 1, width: 'full' }, + { name: 'termsAccepted', label: 'I have read and accept the Terms and Conditions of the Monaco Ocean Protection Challenge', fieldType: 'CHECKBOX', required: true, sortOrder: 2, width: 'full' }, + ], + }, +] + +async function main() { + console.log('Seeding MOPC onboarding form...') + + // Check if form already exists + const existingForm = await prisma.applicationForm.findUnique({ + where: { publicSlug: MOPC_FORM_CONFIG.publicSlug }, + }) + + if (existingForm) { + console.log('Form with slug "mopc-2026" already exists. Updating...') + + // Delete existing steps and fields to recreate them + await prisma.applicationFormField.deleteMany({ where: { formId: existingForm.id } }) + await prisma.onboardingStep.deleteMany({ where: { formId: existingForm.id } }) + + // Update the form + await prisma.applicationForm.update({ + where: { id: existingForm.id }, + data: { + name: MOPC_FORM_CONFIG.name, + description: MOPC_FORM_CONFIG.description, + status: MOPC_FORM_CONFIG.status, + isPublic: MOPC_FORM_CONFIG.isPublic, + sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail, + sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails, + confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject, + confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody, + confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage, + }, + }) + + // Create steps and fields + for (const stepData of STEPS) { + const step = await prisma.onboardingStep.create({ + data: { + formId: existingForm.id, + name: stepData.name, + title: stepData.title, + description: stepData.description, + sortOrder: stepData.sortOrder, + isOptional: stepData.isOptional, + }, + }) + + for (const field of stepData.fields) { + await prisma.applicationFormField.create({ + data: { + formId: existingForm.id, + stepId: step.id, + name: field.name, + label: field.label, + fieldType: field.fieldType, + specialType: field.specialType || null, + required: field.required, + sortOrder: field.sortOrder, + width: field.width, + description: field.description || null, + placeholder: field.placeholder || null, + projectMapping: field.projectMapping || null, + minLength: field.minLength || null, + maxLength: field.maxLength || null, + optionsJson: field.optionsJson || undefined, + conditionJson: field.conditionJson || undefined, + }, + }) + } + console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`) + } + + console.log(`\nForm updated: ${existingForm.id}`) + return + } + + // Create new form + const form = await prisma.applicationForm.create({ + data: { + name: MOPC_FORM_CONFIG.name, + description: MOPC_FORM_CONFIG.description, + publicSlug: MOPC_FORM_CONFIG.publicSlug, + status: MOPC_FORM_CONFIG.status, + isPublic: MOPC_FORM_CONFIG.isPublic, + sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail, + sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails, + confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject, + confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody, + confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage, + }, + }) + + console.log(`Created form: ${form.id}`) + + // Create steps and fields + for (const stepData of STEPS) { + const step = await prisma.onboardingStep.create({ + data: { + formId: form.id, + name: stepData.name, + title: stepData.title, + description: stepData.description, + sortOrder: stepData.sortOrder, + isOptional: stepData.isOptional, + }, + }) + + for (const field of stepData.fields) { + await prisma.applicationFormField.create({ + data: { + formId: form.id, + stepId: step.id, + name: field.name, + label: field.label, + fieldType: field.fieldType, + specialType: field.specialType || null, + required: field.required, + sortOrder: field.sortOrder, + width: field.width, + description: field.description || null, + placeholder: field.placeholder || null, + projectMapping: field.projectMapping || null, + minLength: field.minLength || null, + maxLength: field.maxLength || null, + optionsJson: field.optionsJson || undefined, + conditionJson: field.conditionJson || undefined, + }, + }) + } + console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`) + } + + console.log(`\nMOPC form seeded successfully!`) + console.log(`Form ID: ${form.id}`) + console.log(`Public URL: /apply/${form.publicSlug}`) +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/prisma/seed-notification-settings.mjs b/prisma/seed-notification-settings.mjs new file mode 100644 index 0000000..59ef2c5 --- /dev/null +++ b/prisma/seed-notification-settings.mjs @@ -0,0 +1,73 @@ +/** + * Seed script for notification email settings (ESM version for production) + * Run with: node prisma/seed-notification-settings.mjs + */ + +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +const NOTIFICATION_EMAIL_SETTINGS = [ + // Team / Applicant notifications + { notificationType: 'APPLICATION_SUBMITTED', category: 'team', label: 'Application Submitted', description: 'When a team submits their application', sendEmail: true }, + { notificationType: 'TEAM_INVITE_RECEIVED', category: 'team', label: 'Team Invitation Received', description: 'When someone is invited to join a team', sendEmail: true }, + { notificationType: 'TEAM_MEMBER_JOINED', category: 'team', label: 'Team Member Joined', description: 'When a new member joins the team', sendEmail: false }, + { notificationType: 'ADVANCED_SEMIFINAL', category: 'team', label: 'Advanced to Semi-Finals', description: 'When a project advances to semi-finals', sendEmail: true }, + { notificationType: 'ADVANCED_FINAL', category: 'team', label: 'Selected as Finalist', description: 'When a project is selected as a finalist', sendEmail: true }, + { notificationType: 'MENTOR_ASSIGNED', category: 'team', label: 'Mentor Assigned', description: 'When a mentor is assigned to the team', sendEmail: true }, + { notificationType: 'NOT_SELECTED', category: 'team', label: 'Not Selected', description: 'When a project is not selected for the next round', sendEmail: true }, + { notificationType: 'FEEDBACK_AVAILABLE', category: 'team', label: 'Feedback Available', description: 'When jury feedback becomes available', sendEmail: true }, + { notificationType: 'WINNER_ANNOUNCEMENT', category: 'team', label: 'Winner Announcement', description: 'When a project wins an award', sendEmail: true }, + + // Jury notifications + { notificationType: 'ASSIGNED_TO_PROJECT', category: 'jury', label: 'Assigned to Project', description: 'When a jury member is assigned to a project', sendEmail: true }, + { notificationType: 'BATCH_ASSIGNED', category: 'jury', label: 'Batch Assignment', description: 'When multiple projects are assigned at once', sendEmail: true }, + { notificationType: 'ROUND_NOW_OPEN', category: 'jury', label: 'Round Now Open', description: 'When a round opens for evaluation', sendEmail: true }, + { notificationType: 'REMINDER_24H', category: 'jury', label: 'Reminder (24h)', description: 'Reminder 24 hours before deadline', sendEmail: true }, + { notificationType: 'REMINDER_1H', category: 'jury', label: 'Reminder (1h)', description: 'Urgent reminder 1 hour before deadline', sendEmail: true }, + { notificationType: 'ROUND_CLOSED', category: 'jury', label: 'Round Closed', description: 'When a round closes', sendEmail: false }, + { notificationType: 'AWARD_VOTING_OPEN', category: 'jury', label: 'Award Voting Open', description: 'When special award voting opens', sendEmail: true }, + + // Mentor notifications + { notificationType: 'MENTEE_ASSIGNED', category: 'mentor', label: 'Mentee Assigned', description: 'When assigned as mentor to a project', sendEmail: true }, + { notificationType: 'MENTEE_UPLOADED_DOCS', category: 'mentor', label: 'Mentee Documents Updated', description: 'When a mentee uploads new documents', sendEmail: false }, + { notificationType: 'MENTEE_ADVANCED', category: 'mentor', label: 'Mentee Advanced', description: 'When a mentee advances to the next round', sendEmail: true }, + { notificationType: 'MENTEE_FINALIST', category: 'mentor', label: 'Mentee is Finalist', description: 'When a mentee is selected as finalist', sendEmail: true }, + { notificationType: 'MENTEE_WON', category: 'mentor', label: 'Mentee Won', description: 'When a mentee wins an award', sendEmail: true }, + + // Observer notifications + { notificationType: 'ROUND_STARTED', category: 'observer', label: 'Round Started', description: 'When a new round begins', sendEmail: false }, + { notificationType: 'ROUND_COMPLETED', category: 'observer', label: 'Round Completed', description: 'When a round is completed', sendEmail: true }, + { notificationType: 'FINALISTS_ANNOUNCED', category: 'observer', label: 'Finalists Announced', description: 'When finalists are announced', sendEmail: true }, + { notificationType: 'WINNERS_ANNOUNCED', category: 'observer', label: 'Winners Announced', description: 'When winners are announced', sendEmail: true }, + + // Admin notifications + { notificationType: 'FILTERING_COMPLETE', category: 'admin', label: 'AI Filtering Complete', description: 'When AI filtering job completes', sendEmail: false }, + { notificationType: 'FILTERING_FAILED', category: 'admin', label: 'AI Filtering Failed', description: 'When AI filtering job fails', sendEmail: true }, + { notificationType: 'NEW_APPLICATION', category: 'admin', label: 'New Application', description: 'When a new application is received', sendEmail: false }, + { notificationType: 'SYSTEM_ERROR', category: 'admin', label: 'System Error', description: 'When a system error occurs', sendEmail: true }, +] + +async function main() { + console.log('Seeding notification email settings...') + + for (const setting of NOTIFICATION_EMAIL_SETTINGS) { + await prisma.notificationEmailSetting.upsert({ + where: { notificationType: setting.notificationType }, + update: { category: setting.category, label: setting.label, description: setting.description }, + create: setting, + }) + console.log(` - ${setting.label}`) + } + + console.log(`\nSeeded ${NOTIFICATION_EMAIL_SETTINGS.length} notification email settings.`) +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx index d615f9f..992deaa 100644 --- a/src/app/(admin)/admin/projects/page.tsx +++ b/src/app/(admin)/admin/projects/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react' import Link from 'next/link' import { useSearchParams, usePathname } from 'next/navigation' import { trpc } from '@/lib/trpc/client' +import { toast } from 'sonner' import { Card, CardContent, @@ -27,8 +28,19 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { Plus, MoreHorizontal, @@ -38,6 +50,8 @@ import { FileUp, Users, Search, + Trash2, + Loader2, } from 'lucide-react' import { truncate } from '@/lib/utils' import { ProjectLogo } from '@/components/shared/project-logo' @@ -210,9 +224,30 @@ export default function ProjectsPage() { perPage: PER_PAGE, } + const utils = trpc.useUtils() const { data, isLoading } = trpc.project.list.useQuery(queryInput) const { data: filterOptions } = trpc.project.getFilterOptions.useQuery() + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null) + + const deleteProject = trpc.project.delete.useMutation({ + onSuccess: () => { + toast.success('Project deleted successfully') + utils.project.list.invalidate() + setDeleteDialogOpen(false) + setProjectToDelete(null) + }, + onError: (error) => { + toast.error(error.message || 'Failed to delete project') + }, + }) + + const handleDeleteClick = (project: { id: string; title: string }) => { + setProjectToDelete(project) + setDeleteDialogOpen(true) + } + return (
{/* Header */} @@ -391,6 +426,17 @@ export default function ProjectsPage() { Edit + + { + e.stopPropagation() + handleDeleteClick({ id: project.id, title: project.title }) + }} + > + + Delete + @@ -459,6 +505,32 @@ export default function ProjectsPage() { /> ) : null} + + {/* Delete Confirmation Dialog */} + + + + Delete Project + + Are you sure you want to delete "{projectToDelete?.title}"? This will + permanently remove the project, all its files, assignments, and evaluations. + This action cannot be undone. + + + + Cancel + projectToDelete && deleteProject.mutate({ id: projectToDelete.id })} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleteProject.isPending ? ( + + ) : null} + Delete + + + +
) } diff --git a/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx b/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx index 685ba4e..9502aa7 100644 --- a/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx @@ -339,7 +339,7 @@ export default function FilteringResultsPage({
- {/* Rule Results */} + {/* Rule Results (non-AI rules only, AI shown separately) */}

Rule Results @@ -355,7 +355,7 @@ export default function FilteringResultsPage({ action: string reasoning?: string }> - ).map((rr, i) => ( + ).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => (

{ if (!newRuleName.trim()) return @@ -143,6 +145,8 @@ export default function FilteringRulesPage({ configJson = { criteriaText, action: 'FLAG', + batchSize: parseInt(aiBatchSize) || 20, + parallelBatches: parseInt(aiParallelBatches) || 1, } } @@ -195,6 +199,8 @@ export default function FilteringRulesPage({ setMinFileCount('1') setDocAction('REJECT') setCriteriaText('') + setAiBatchSize('20') + setAiParallelBatches('1') } if (isLoading) { @@ -403,18 +409,65 @@ export default function FilteringRulesPage({ {/* AI Screening Config */} {newRuleType === 'AI_SCREENING' && ( -
- -