diff --git a/CLAUDE.md b/CLAUDE.md index c5f0170..2166654 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,8 +22,11 @@ The platform is designed for future expansion into a comprehensive program manag | First Admin | Database seed script | | Past Evaluations | Visible read-only after submit | | Grace Period | Admin-configurable per juror/project | -| Smart Assignment | AI-powered (GPT) + Smart Algorithm fallback | +| Smart Assignment | AI-powered (GPT) + Smart Algorithm fallback + geo-diversity, familiarity, COI scoring | | AI Data Privacy | All data anonymized before sending to GPT | +| Evaluation Criteria Types | `numeric`, `text`, `boolean`, `section_header` (backward-compatible) | +| COI Workflow | Mandatory declaration before evaluation, admin review | +| Evaluation Reminders | Cron-based email reminders with countdown urgency | ## Brand Identity @@ -83,14 +86,22 @@ mopc-platform/ │ │ ├── (admin)/ # Admin dashboard (protected) │ │ ├── (jury)/ # Jury interface (protected) │ │ ├── api/ # API routes -│ │ │ └── trpc/ # tRPC endpoint +│ │ │ ├── trpc/ # tRPC endpoint +│ │ │ └── cron/ +│ │ │ └── reminders/ # Cron endpoint for evaluation reminders (F4) │ │ ├── layout.tsx # Root layout │ │ └── page.tsx # Home/landing │ ├── components/ │ │ ├── ui/ # shadcn/ui components -│ │ ├── forms/ # Form components (evaluation, etc.) +│ │ ├── admin/ # Admin-specific components +│ │ │ └── evaluation-summary-card.tsx # AI summary display +│ │ ├── forms/ # Form components +│ │ │ ├── evaluation-form.tsx # With progress indicator (F1) +│ │ │ ├── coi-declaration-dialog.tsx # COI blocking dialog (F5) +│ │ │ └── evaluation-form-with-coi.tsx # COI-gated wrapper (F5) │ │ ├── layouts/ # Layout components (sidebar, nav) │ │ └── shared/ # Shared components +│ │ └── countdown-timer.tsx # Live countdown with urgency (F4) │ ├── lib/ │ │ ├── auth.ts # NextAuth configuration │ │ ├── prisma.ts # Prisma client singleton @@ -107,8 +118,14 @@ mopc-platform/ │ │ │ ├── evaluation.ts │ │ │ ├── audit.ts │ │ │ ├── settings.ts -│ │ │ └── gracePeriod.ts +│ │ │ ├── gracePeriod.ts +│ │ │ ├── export.ts # CSV export incl. filtering results (F2) +│ │ │ ├── analytics.ts # Reports/analytics (observer access, F3) +│ │ │ └── mentor.ts # Mentor messaging endpoints (F10) │ │ ├── services/ # Business logic services +│ │ │ ├── smart-assignment.ts # With geo/familiarity/COI scoring (F8) +│ │ │ ├── evaluation-reminders.ts # Email reminder service (F4) +│ │ │ └── ai-evaluation-summary.ts # GPT summary generation (F7) │ │ └── middleware/ # RBAC & auth middleware │ ├── hooks/ # React hooks │ ├── types/ # Shared TypeScript types @@ -240,8 +257,11 @@ SMTP_USER="noreply@monaco-opc.com" SMTP_PASS="your-smtp-password" EMAIL_FROM="MOPC Platform " -# OpenAI (for smart assignment) +# OpenAI (for smart assignment and AI evaluation summaries) OPENAI_API_KEY="your-openai-api-key" + +# Cron (for scheduled evaluation reminders) +CRON_SECRET="your-cron-secret-key" ``` ## Key Architectural Decisions @@ -273,8 +293,10 @@ OPENAI_API_KEY="your-openai-api-key" |------|------------| | **SUPER_ADMIN** | Full system access, all programs, user management | | **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury | -| **JURY_MEMBER** | View assigned projects only, submit evaluations | -| **OBSERVER** | Read-only access to dashboards (optional) | +| **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI | +| **OBSERVER** | Read-only access to dashboards, all analytics/reports | +| **MENTOR** | View assigned projects, message applicants via `mentorProcedure` | +| **APPLICANT** | View own project status, upload documents per round, message mentor | ## Important Constraints @@ -286,6 +308,12 @@ OPENAI_API_KEY="your-openai-api-key" 6. **Mobile responsiveness is mandatory** - every view must work on phones 7. **File downloads require project authorization** - jury/mentor must be assigned to the project 8. **Mentor endpoints require MENTOR role** - uses `mentorProcedure` middleware +9. **COI declaration required before evaluation** - blocking dialog gates evaluation form; admin reviews COI declarations +10. **Evaluation form supports multiple criterion types** - `numeric`, `text`, `boolean`, `section_header`; defaults to `numeric` for backward compatibility +11. **Smart assignment respects COI** - jurors with declared conflicts are skipped entirely; geo-diversity penalty and prior-round familiarity bonus applied +12. **Cron endpoints protected by CRON_SECRET** - `/api/cron/reminders` validates secret header +13. **Project status changes tracked** - every status update creates a `ProjectStatusHistory` record +14. **Per-round document management** - `ProjectFile` supports `roundId` scoping and `isLate` deadline tracking ## Security Notes @@ -326,9 +354,18 @@ The MOPC platform connects to these via environment variables. - Progress dashboards - CSV export - Audit logging +- **F1: Evaluation progress indicator** - sticky status bar with percentage tracking across criteria, global score, decision, feedback +- **F2: Export filtering results as CSV** - dynamic AI column flattening from `aiScreeningJson` +- **F3: Observer access to reports/analytics** - all 8 analytics procedures use `observerProcedure`; observer reports page with round selector, tabs, charts +- **F4: Countdown timer + email reminders** - live countdown with urgency colors; `EvaluationRemindersService` with cron endpoint (`/api/cron/reminders`) +- **F5: Conflict of Interest declaration** - `ConflictOfInterest` model; blocking dialog before evaluation; admin COI review page +- **F6: Bulk status update UI** - checkbox selection, floating toolbar, `ProjectStatusHistory` tracking +- **F7: AI-powered evaluation summary** - `EvaluationSummary` model; GPT-generated strengths/weaknesses, themes, scoring stats +- **F8: Smart assignment improvements** - `geoDiversityPenalty`, `previousRoundFamiliarity`, `coiPenalty` scoring factors +- **F9: Evaluation form flexibility** - extended criterion types (`numeric`, `text`, `boolean`, `section_header`); conditional visibility, section grouping +- **F10: Applicant portal enhancements** - `ProjectStatusHistory` timeline; per-round document management (`roundId` + `isLate` on `ProjectFile`); `MentorMessage` model for mentor-applicant chat ### Out of Scope (Phase 2+) -- Auto-assignment algorithm - Typeform/Notion integrations - WhatsApp notifications - Learning hub diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev index 547d9df..cc36a9b 100644 --- a/docker/Dockerfile.dev +++ b/docker/Dockerfile.dev @@ -25,7 +25,13 @@ RUN npx prisma generate COPY . . # Build the application at build time (pre-compile everything) +# Dummy env vars needed so Next.js can collect page data without MinIO throwing +ENV MINIO_ACCESS_KEY=build-placeholder +ENV MINIO_SECRET_KEY=build-placeholder RUN npm run build +# Clear build-time placeholders (runtime uses real values from docker-compose) +ENV MINIO_ACCESS_KEY= +ENV MINIO_SECRET_KEY= # Expose port EXPOSE 3000 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0db3359..cb1e969 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -65,16 +65,25 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty │ PRESENTATION LAYER │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ -│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ Admin Views │ │ Jury Views │ │ Auth Views │ │ -│ │ │ │ │ │ │ │ -│ │ - Dashboard │ │ - Project List │ │ - Login │ │ -│ │ - Rounds │ │ - Project View │ │ - Magic Link │ │ -│ │ - Projects │ │ - Evaluation │ │ - Verify │ │ -│ │ - Jury Mgmt │ │ - My Progress │ │ │ │ -│ │ - Assignments │ │ │ │ │ │ -│ │ - Reports │ │ │ │ │ │ -│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Admin Views │ │ Jury Views │ │Applicant View│ │ Mentor Views │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ - Dashboard │ │ - Project Ls │ │ - Status │ │ - Assigned │ │ +│ │ - Rounds │ │ - Project Vw │ │ Tracker │ │ Projects │ │ +│ │ - Projects │ │ - Evaluation │ │ - Document │ │ - Messaging │ │ +│ │ - Jury Mgmt │ │ - My Progress│ │ Uploads │ │ │ │ +│ │ - Assignments│ │ - COI Decl. │ │ - Mentor │ │ │ │ +│ │ - Reports │ │ - Countdown │ │ Chat │ │ │ │ +│ │ - COI Review │ │ │ │ │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Auth Views │ │ Observer Views │ │ +│ │ │ │ │ │ +│ │ - Login │ │ - Reports/ │ │ +│ │ - Magic Link │ │ Analytics │ │ +│ │ - Verify │ │ - Round Stats │ │ +│ └──────────────────┘ └──────────────────┘ │ │ │ │ Built with: Next.js App Router + React Server Components + shadcn/ui │ └─────────────────────────────────────────────────────────────────────────────┘ @@ -116,6 +125,11 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty │ │ Service │ │ Service │ │ Service │ │ Service │ │ │ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │ │ │ +│ ┌────────────┐ ┌────────────┐ │ +│ │ Evaluation │ │ AI Eval │ │ +│ │ Reminders │ │ Summary │ │ +│ └────────────┘ └────────────┘ │ +│ │ └─────────────────────────────────────────────────────────────────────────────┘ │ ▼ @@ -151,8 +165,11 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty | Component | Responsibility | |-----------|----------------| -| **Admin Views** | Program/round management, project import, jury management, assignments, dashboards | -| **Jury Views** | View assigned projects, evaluate projects, track progress | +| **Admin Views** | Program/round management, project import, jury management, assignments, dashboards, COI review, bulk status updates, AI evaluation summaries | +| **Jury Views** | View assigned projects, evaluate projects, track progress, COI declarations, countdown timer | +| **Applicant Views** | Project status tracker, document uploads (per-round with deadline policy), mentor chat | +| **Mentor Views** | View assigned projects, messaging with applicants | +| **Observer Views** | Read-only access to all reports/analytics (round selector, tabs, all chart components) | | **Auth Views** | Login, magic link verification, session management | | **Layouts** | Responsive navigation, sidebar, mobile adaptations | | **UI Components** | shadcn/ui based, reusable, accessible | @@ -177,8 +194,10 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty | **EvaluationService** | Form submission, autosave, scoring | | **FileService** | MinIO uploads, pre-signed URLs | | **EmailService** | Magic links, notifications via Nodemailer | -| **ExportService** | CSV/Excel generation | +| **ExportService** | CSV/Excel generation, filtering results export with AI column flattening | | **AuditService** | Immutable event logging | +| **EvaluationRemindersService** | Finds incomplete assignments, sends email reminders with countdown urgency | +| **AIEvaluationSummaryService** | Anonymizes evaluation data, generates GPT-powered summaries with scoring patterns, strengths/weaknesses | ### Data Layer @@ -345,8 +364,14 @@ The platform includes two assignment modes: ``` Score = (expertise_match × 40) + (load_balance × 25) + (specialty_match × 20) + (diversity × 10) - (conflict × 100) + - (geo_diversity_penalty) + (previous_round_familiarity) - (coi_penalty) ``` +**Additional Scoring Factors** (added in Phase 1 enhancements): +- **geoDiversityPenalty**: -15 per excess same-country assignment beyond threshold of 2 +- **previousRoundFamiliarity**: +10 bonus for jurors who reviewed the same project in a prior round +- **coiPenalty**: Jurors with a declared Conflict of Interest are skipped entirely + ## Admin Settings Panel Centralized configuration for: @@ -363,8 +388,10 @@ Centralized configuration for: |------|------------| | **SUPER_ADMIN** | Full system access, all programs, user management | | **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury | -| **JURY_MEMBER** | View assigned projects only, submit evaluations | -| **OBSERVER** | Read-only access to dashboards | +| **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI | +| **OBSERVER** | Read-only access to dashboards and all analytics/reports | +| **MENTOR** | View assigned projects, message applicants via `mentorProcedure` | +| **APPLICANT** | View own project status, upload documents per round, message mentor | ## Related Documentation diff --git a/docs/architecture/api.md b/docs/architecture/api.md index 6c76507..f3d8518 100644 --- a/docs/architecture/api.md +++ b/docs/architecture/api.md @@ -119,6 +119,9 @@ export const hasRole = (...roles: UserRole[]) => export const protectedProcedure = t.procedure.use(isAuthenticated) export const adminProcedure = t.procedure.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN')) export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER')) +export const observerProcedure = t.procedure.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER')) +export const mentorProcedure = t.procedure.use(hasRole('MENTOR')) +export const applicantProcedure = t.procedure.use(hasRole('APPLICANT')) ``` ## Router Structure @@ -136,7 +139,9 @@ src/server/routers/ ├── export.ts # Export operations ├── audit.ts # Audit log access ├── settings.ts # Platform settings (admin) -└── gracePeriod.ts # Grace period management +├── gracePeriod.ts # Grace period management +├── analytics.ts # Reports & analytics (admin + observer) +└── mentor.ts # Mentor messaging endpoints ``` ### Root Router @@ -1024,6 +1029,164 @@ export const gracePeriodRouter = router({ }) ``` +### Conflict of Interest Endpoints (Evaluation Router) + +```typescript +// Added to src/server/routers/evaluation.ts + +// Declare COI for an assignment (jury member) +declareCOI: protectedProcedure + .input(z.object({ + assignmentId: z.string(), + hasConflict: z.boolean(), + conflictType: z.string().optional(), + description: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + // Creates/updates ConflictOfInterest record + // Blocks evaluation access if hasConflict = true until reviewed + }), + +// Get COI status for an assignment +getCOIStatus: protectedProcedure + .input(z.object({ assignmentId: z.string() })) + .query(async ({ ctx, input }) => { + // Returns COI declaration status for the assignment + }), + +// List all COI declarations for a round (admin only) +listCOIByRound: adminProcedure + .input(z.object({ roundId: z.string() })) + .query(async ({ ctx, input }) => { + // Returns all COI declarations with user and project info + }), + +// Review a COI declaration (admin only) +reviewCOI: adminProcedure + .input(z.object({ + id: z.string(), + reviewNotes: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + // Marks COI as reviewed, logs audit event + }), +``` + +### AI Evaluation Summary Endpoints (Evaluation Router) + +```typescript +// Added to src/server/routers/evaluation.ts + +// Generate AI summary for a project's evaluations +generateSummary: adminProcedure + .input(z.object({ + projectId: z.string(), + roundId: z.string(), + })) + .mutation(async ({ ctx, input }) => { + // Anonymizes evaluation data, sends to GPT + // Generates strengths/weaknesses, themes, scoring patterns + // Stores EvaluationSummary record + }), + +// Get existing summary for a project +getSummary: adminProcedure + .input(z.object({ + projectId: z.string(), + roundId: z.string(), + })) + .query(async ({ ctx, input }) => { + // Returns EvaluationSummary with parsed summaryJson + }), + +// Generate summaries for all projects in a round +generateBulkSummaries: adminProcedure + .input(z.object({ + roundId: z.string(), + })) + .mutation(async ({ ctx, input }) => { + // Iterates through projects, generates summaries in batch + // Returns count of generated summaries + }), +``` + +### Evaluation Reminders Endpoint + +```typescript +// Added to src/server/routers/evaluation.ts + +// Trigger reminder emails for incomplete assignments (admin) +triggerReminders: adminProcedure + .input(z.object({ + roundId: z.string(), + })) + .mutation(async ({ ctx, input }) => { + // Calls EvaluationRemindersService + // Finds incomplete assignments, sends email reminders + // Logs to ReminderLog table + }), +``` + +### Cron API Route + +```typescript +// src/app/api/cron/reminders/route.ts + +// POST /api/cron/reminders +// Protected by CRON_SECRET header validation +// Automatically finds rounds with approaching deadlines +// Sends reminder emails to jurors with incomplete evaluations +// Designed to be called by external cron scheduler +``` + +### Export Router - Filtering Results + +```typescript +// Added to src/server/routers/export.ts + +// Export filtering results as CSV +filteringResults: adminProcedure + .input(z.object({ + roundId: z.string(), + })) + .query(async ({ ctx, input }) => { + // Queries projects with evaluations and AI screening data + // Dynamically flattens aiScreeningJson columns + // Returns CSV-ready data + }), +``` + +### Evaluation Form Schema - Extended Criterion Types + +The evaluation form `criteriaJson` now supports extended criterion types: + +```typescript +type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header' + +type Criterion = { + id: string + label: string + type: CriterionType // Defaults to 'numeric' for backward compatibility + scale?: string // Only for 'numeric' type + weight?: number // Only for 'numeric' type + required?: boolean + description?: string + // Conditional visibility + visibleWhen?: { + criterionId: string + value: unknown + } + // Section grouping + section?: string +} +``` + +Field components per type: +- **numeric**: Standard slider/number input with scale +- **text**: Free-text textarea field (`TextCriterionField`) +- **boolean**: Yes/No toggle (`BooleanCriterionField`) +- **section_header**: Non-input visual divider for form organization (`SectionHeader`) + ## Authentication Flow ### Magic Link Implementation diff --git a/docs/architecture/database.md b/docs/architecture/database.md index 6f2816c..1d0a2db 100644 --- a/docs/architecture/database.md +++ b/docs/architecture/database.md @@ -112,6 +112,72 @@ The MOPC platform uses PostgreSQL as its primary database, accessed via Prisma O │ grantedBy │ │ createdAt │ └─────────────────────┘ + +┌─────────────────────┐ +│ ReminderLog │ +│─────────────────────│ +│ id │ +│ roundId │ +│ userId │ +│ assignmentId │ +│ type │ +│ sentAt │ +│ emailTo │ +│ status │ +└─────────────────────┘ + +┌───────────────────────┐ +│ ConflictOfInterest │ +│───────────────────────│ +│ id │ +│ assignmentId │ +│ userId │ +│ projectId │ +│ roundId │ +│ hasConflict │ +│ conflictType │ +│ description │ +│ reviewedBy │ +│ reviewedAt │ +│ reviewNotes │ +│ createdAt │ +└───────────────────────┘ + +┌───────────────────────┐ +│ EvaluationSummary │ +│───────────────────────│ +│ id │ +│ projectId │ +│ roundId │ +│ summaryJson │ +│ model │ +│ tokensUsed │ +│ createdAt │ +└───────────────────────┘ + +┌───────────────────────┐ +│ ProjectStatusHistory │ +│───────────────────────│ +│ id │ +│ projectId │ +│ oldStatus │ +│ newStatus │ +│ changedBy │ +│ reason │ +│ createdAt │ +└───────────────────────┘ + +┌───────────────────────┐ +│ MentorMessage │ +│───────────────────────│ +│ id │ +│ projectId │ +│ senderId │ +│ recipientId │ +│ content │ +│ readAt │ +│ createdAt │ +└───────────────────────┘ ``` ## Prisma Schema @@ -137,6 +203,8 @@ enum UserRole { PROGRAM_ADMIN JURY_MEMBER OBSERVER + MENTOR + APPLICANT } enum UserStatus { @@ -357,12 +425,14 @@ model Project { model ProjectFile { id String @id @default(cuid()) projectId String + roundId String? // Per-round document management (Phase B) // File info fileType FileType fileName String mimeType String size Int // bytes + isLate Boolean @default(false) // Upload deadline policy tracking // MinIO location bucket String @@ -376,6 +446,7 @@ model ProjectFile { // Indexes @@index([projectId]) @@index([fileType]) + @@index([roundId]) @@unique([bucket, objectKey]) } @@ -475,6 +546,119 @@ model AuditLog { @@index([entityType, entityId]) @@index([timestamp]) } + +// ============================================================================= +// REMINDER LOGGING +// ============================================================================= + +model ReminderLog { + id String @id @default(cuid()) + roundId String + userId String + assignmentId String + type String // e.g., "DEADLINE_APPROACHING", "OVERDUE" + sentAt DateTime @default(now()) + emailTo String + status String // "SENT", "FAILED" + + // Indexes + @@index([roundId]) + @@index([userId]) + @@index([assignmentId]) +} + +// ============================================================================= +// CONFLICT OF INTEREST +// ============================================================================= + +model ConflictOfInterest { + id String @id @default(cuid()) + assignmentId String + userId String + projectId String + roundId String + hasConflict Boolean + conflictType String? // e.g., "FINANCIAL", "PERSONAL", "ORGANIZATIONAL" + description String? @db.Text + + // Review fields (admin) + reviewedBy String? + reviewedAt DateTime? + reviewNotes String? @db.Text + + createdAt DateTime @default(now()) + + // Relations + assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + + // Indexes + @@unique([assignmentId]) + @@index([userId]) + @@index([roundId]) + @@index([hasConflict]) +} + +// ============================================================================= +// AI EVALUATION SUMMARY +// ============================================================================= + +model EvaluationSummary { + id String @id @default(cuid()) + projectId String + roundId String + summaryJson Json @db.JsonB // Strengths, weaknesses, themes, scoring stats + model String // e.g., "gpt-4o" + tokensUsed Int + + createdAt DateTime @default(now()) + + // Indexes + @@unique([projectId, roundId]) + @@index([roundId]) +} + +// ============================================================================= +// PROJECT STATUS HISTORY +// ============================================================================= + +model ProjectStatusHistory { + id String @id @default(cuid()) + projectId String + oldStatus String + newStatus String + changedBy String? // User who changed the status + reason String? @db.Text + + createdAt DateTime @default(now()) + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + // Indexes + @@index([projectId]) + @@index([createdAt]) +} + +// ============================================================================= +// MENTOR MESSAGING +// ============================================================================= + +model MentorMessage { + id String @id @default(cuid()) + projectId String + senderId String + recipientId String + content String @db.Text + readAt DateTime? + + createdAt DateTime @default(now()) + + // Indexes + @@index([projectId]) + @@index([senderId]) + @@index([recipientId]) + @@index([createdAt]) +} ``` ## Indexing Strategy @@ -505,6 +689,22 @@ model AuditLog { | Evaluation | `submittedAt` | Sort by submission time | | AuditLog | `timestamp` | Time-based queries | | AuditLog | `entityType, entityId` | Entity history | +| ProjectFile | `roundId` | Per-round document listing | +| ReminderLog | `roundId` | Reminders per round | +| ReminderLog | `userId` | User reminder history | +| ReminderLog | `assignmentId` | Assignment reminder tracking | +| ConflictOfInterest | `assignmentId` (unique) | COI per assignment | +| ConflictOfInterest | `userId` | User COI declarations | +| ConflictOfInterest | `roundId` | COI per round | +| ConflictOfInterest | `hasConflict` | Filter active conflicts | +| EvaluationSummary | `projectId, roundId` (unique) | One summary per project per round | +| EvaluationSummary | `roundId` | Summaries per round | +| ProjectStatusHistory | `projectId` | Status change timeline | +| ProjectStatusHistory | `createdAt` | Chronological ordering | +| MentorMessage | `projectId` | Messages per project | +| MentorMessage | `senderId` | Sent messages | +| MentorMessage | `recipientId` | Received messages | +| MentorMessage | `createdAt` | Chronological ordering | ### JSON Field Indexes diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5582295..dac9731 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -256,6 +256,19 @@ model User { notifications InAppNotification[] @relation("UserNotifications") notificationSettingsUpdated NotificationEmailSetting[] @relation("NotificationSettingUpdater") + // Reminder logs + reminderLogs ReminderLog[] + + // Conflict of interest + conflictsOfInterest ConflictOfInterest[] + coiReviews ConflictOfInterest[] @relation("COIReviewedBy") + + // Evaluation summaries + generatedSummaries EvaluationSummary[] @relation("EvaluationSummaryGeneratedBy") + + // Mentor messages + mentorMessages MentorMessage[] @relation("MentorMessageSender") + // NextAuth relations accounts Account[] sessions Session[] @@ -377,6 +390,8 @@ model Round { filteringJobs FilteringJob[] assignmentJobs AssignmentJob[] taggingJobs TaggingJob[] + reminderLogs ReminderLog[] + projectFiles ProjectFile[] @@index([programId]) @@index([status]) @@ -480,6 +495,9 @@ model Project { awardVotes AwardVote[] wonAwards SpecialAward[] @relation("AwardWinner") projectTags ProjectTag[] + statusHistory ProjectStatusHistory[] + mentorMessages MentorMessage[] + evaluationSummaries EvaluationSummary[] @@index([roundId]) @@index([status]) @@ -494,6 +512,7 @@ model Project { model ProjectFile { id String @id @default(cuid()) projectId String + roundId String? // Which round this file was submitted for // File info fileType FileType @@ -505,13 +524,17 @@ model ProjectFile { bucket String objectKey String + isLate Boolean @default(false) // Uploaded after round deadline + createdAt DateTime @default(now()) // Relations project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + round Round? @relation(fields: [roundId], references: [id]) @@unique([bucket, objectKey]) @@index([projectId]) + @@index([roundId]) @@index([fileType]) } @@ -539,10 +562,11 @@ model Assignment { createdBy String? // Admin who created the assignment // Relations - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) - evaluation Evaluation? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + evaluation Evaluation? + conflictOfInterest ConflictOfInterest? @@unique([userId, projectId, roundId]) @@index([userId]) @@ -1297,3 +1321,109 @@ model AwardVote { @@index([userId]) @@index([projectId]) } + +// ============================================================================= +// REMINDER LOG (Evaluation Deadline Reminders) +// ============================================================================= + +model ReminderLog { + id String @id @default(cuid()) + roundId String + userId String + type String // "3_DAYS", "24H", "1H" + sentAt DateTime @default(now()) + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([roundId, userId, type]) + @@index([roundId]) +} + +// ============================================================================= +// CONFLICT OF INTEREST +// ============================================================================= + +model ConflictOfInterest { + id String @id @default(cuid()) + assignmentId String @unique + userId String + projectId String + roundId String + hasConflict Boolean @default(false) + conflictType String? // "financial", "personal", "organizational", "other" + description String? @db.Text + declaredAt DateTime @default(now()) + + // Admin review + reviewedById String? + reviewedAt DateTime? + reviewAction String? // "cleared", "reassigned", "noted" + + // Relations + assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id]) + reviewedBy User? @relation("COIReviewedBy", fields: [reviewedById], references: [id]) + + @@index([userId]) + @@index([roundId, hasConflict]) +} + +// ============================================================================= +// AI EVALUATION SUMMARY +// ============================================================================= + +model EvaluationSummary { + id String @id @default(cuid()) + projectId String + roundId String + summaryJson Json @db.JsonB + generatedAt DateTime @default(now()) + generatedById String + model String + tokensUsed Int + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + generatedBy User @relation("EvaluationSummaryGeneratedBy", fields: [generatedById], references: [id]) + + @@unique([projectId, roundId]) + @@index([roundId]) +} + +// ============================================================================= +// PROJECT STATUS HISTORY +// ============================================================================= + +model ProjectStatusHistory { + id String @id @default(cuid()) + projectId String + status ProjectStatus + changedAt DateTime @default(now()) + changedBy String? + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId, changedAt]) +} + +// ============================================================================= +// MENTOR MESSAGES +// ============================================================================= + +model MentorMessage { + id String @id @default(cuid()) + projectId String + senderId String + message String @db.Text + isRead Boolean @default(false) + createdAt DateTime @default(now()) + + // Relations + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + sender User @relation("MentorMessageSender", fields: [senderId], references: [id]) + + @@index([projectId, createdAt]) +} diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index 8d2a048..b5fc479 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -27,6 +27,7 @@ import { FileViewer } from '@/components/shared/file-viewer' import { FileUpload } from '@/components/shared/file-upload' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' import { UserAvatar } from '@/components/shared/user-avatar' +import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card' import { ArrowLeft, Edit, @@ -635,6 +636,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { )} + + {/* AI Evaluation Summary */} + {project.roundId && stats && stats.totalEvaluations > 0 && ( + + )} ) } diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx index 60d98a2..79eb059 100644 --- a/src/app/(admin)/admin/projects/page.tsx +++ b/src/app/(admin)/admin/projects/page.tsx @@ -67,6 +67,8 @@ import { AlertCircle, Layers, FolderOpen, + X, + AlertTriangle, } from 'lucide-react' import { Select, @@ -75,6 +77,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { Checkbox } from '@/components/ui/checkbox' import { Label } from '@/components/ui/label' import { truncate } from '@/lib/utils' import { ProjectLogo } from '@/components/shared/project-logo' @@ -346,6 +349,77 @@ export default function ProjectsPage() { ? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100) : 0 + // Bulk selection state + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [bulkStatus, setBulkStatus] = useState('') + const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false) + + const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({ + onSuccess: (result) => { + toast.success(`${result.updated} project${result.updated !== 1 ? 's' : ''} updated successfully`) + setSelectedIds(new Set()) + setBulkStatus('') + setBulkConfirmOpen(false) + utils.project.list.invalidate() + }, + onError: (error) => { + toast.error(error.message || 'Failed to update projects') + }, + }) + + const handleToggleSelect = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + } + + const handleSelectAll = () => { + if (!data) return + const allVisible = data.projects.map((p) => p.id) + const allSelected = allVisible.every((id) => selectedIds.has(id)) + if (allSelected) { + setSelectedIds((prev) => { + const next = new Set(prev) + allVisible.forEach((id) => next.delete(id)) + return next + }) + } else { + setSelectedIds((prev) => { + const next = new Set(prev) + allVisible.forEach((id) => next.add(id)) + return next + }) + } + } + + const handleBulkApply = () => { + if (!bulkStatus || selectedIds.size === 0) return + setBulkConfirmOpen(true) + } + + const handleBulkConfirm = () => { + if (!bulkStatus || selectedIds.size === 0 || !filters.roundId) return + bulkUpdateStatus.mutate({ + ids: Array.from(selectedIds), + roundId: filters.roundId, + status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED', + }) + } + + // Determine if all visible projects are selected + const allVisibleSelected = data + ? data.projects.length > 0 && data.projects.every((p) => selectedIds.has(p.id)) + : false + const someVisibleSelected = data + ? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected + : false + const deleteProject = trpc.project.delete.useMutation({ onSuccess: () => { toast.success('Project deleted successfully') @@ -468,6 +542,15 @@ export default function ProjectsPage() { + {filters.roundId && ( + + + + )} Project Round Files @@ -484,6 +567,16 @@ export default function ProjectsPage() { key={project.id} className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`} > + {filters.roundId && ( + + handleToggleSelect(project.id)} + aria-label={`Select ${project.title}`} + onClick={(e) => e.stopPropagation()} + /> + + )} {data.projects.map((project) => ( +
+ {filters.roundId && ( +
+ handleToggleSelect(project.id)} + aria-label={`Select ${project.title}`} + /> +
+ )} -
+
+
))}
@@ -641,6 +744,93 @@ export default function ProjectsPage() { ) : null} + {/* Bulk Action Floating Toolbar */} + {selectedIds.size > 0 && filters.roundId && ( +
+ + + + {selectedIds.size} selected + + +
+ + +
+
+
+
+ )} + + {/* Bulk Status Update Confirmation Dialog */} + + + + Update Project Status + +
+

+ You are about to change the status of{' '} + {selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}{' '} + to {bulkStatus.replace('_', ' ')}. +

+ {bulkStatus === 'REJECTED' && ( +
+ +

+ Warning: Rejected projects will be marked as eliminated. This will send notifications to the project teams. +

+
+ )} +
+
+
+ + Cancel + + {bulkUpdateStatus.isPending ? ( + + ) : null} + Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''} + + +
+
+ {/* Delete Confirmation Dialog */} diff --git a/src/app/(admin)/admin/rounds/[id]/coi/page.tsx b/src/app/(admin)/admin/rounds/[id]/coi/page.tsx new file mode 100644 index 0000000..c79b86f --- /dev/null +++ b/src/app/(admin)/admin/rounds/[id]/coi/page.tsx @@ -0,0 +1,404 @@ +'use client' + +import { Suspense, use, useState } from 'react' +import Link from 'next/link' +import { trpc } from '@/lib/trpc/client' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + ArrowLeft, + ShieldAlert, + CheckCircle2, + AlertCircle, + MoreHorizontal, + ShieldCheck, + UserX, + StickyNote, + Loader2, +} from 'lucide-react' +import { toast } from 'sonner' +import { formatDistanceToNow } from 'date-fns' + +interface PageProps { + params: Promise<{ id: string }> +} + +function COIManagementContent({ roundId }: { roundId: string }) { + const [conflictsOnly, setConflictsOnly] = useState(false) + + const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId }) + const { data: coiList, isLoading: loadingCOI } = trpc.evaluation.listCOIByRound.useQuery({ + roundId, + hasConflictOnly: conflictsOnly || undefined, + }) + + const utils = trpc.useUtils() + const reviewCOI = trpc.evaluation.reviewCOI.useMutation({ + onSuccess: (data) => { + utils.evaluation.listCOIByRound.invalidate({ roundId }) + toast.success(`COI marked as "${data.reviewAction}"`) + }, + onError: (error) => { + toast.error(error.message || 'Failed to review COI') + }, + }) + + if (loadingRound || loadingCOI) { + return + } + + if (!round) { + return ( + + + +

Round Not Found

+ +
+
+ ) + } + + const conflictCount = coiList?.filter((c) => c.hasConflict).length ?? 0 + const totalCount = coiList?.length ?? 0 + const reviewedCount = coiList?.filter((c) => c.reviewAction).length ?? 0 + + const getReviewBadge = (reviewAction: string | null) => { + switch (reviewAction) { + case 'cleared': + return ( + + + Cleared + + ) + case 'reassigned': + return ( + + + Reassigned + + ) + case 'noted': + return ( + + + Noted + + ) + default: + return ( + + Pending Review + + ) + } + } + + const getConflictTypeBadge = (type: string | null) => { + switch (type) { + case 'financial': + return Financial + case 'personal': + return Personal + case 'organizational': + return Organizational + case 'other': + return Other + default: + return null + } + } + + return ( +
+ {/* Header */} +
+ +
+ +
+
+ + {round.program.name} + + / + + {round.name} + +
+

+ + Conflict of Interest Declarations +

+
+ + {/* Stats */} +
+ + + Total Declarations + + + +
{totalCount}
+
+
+ + + + Conflicts Declared + + + +
{conflictCount}
+
+
+ + + + Reviewed + + + +
{reviewedCount}
+
+
+
+ + {/* COI Table */} + + +
+
+ Declarations + + Review and manage conflict of interest declarations from jury members + +
+
+ + +
+
+
+ + {coiList && coiList.length > 0 ? ( +
+
+ + + Project + Juror + Conflict + Type + Description + Status + Actions + + + + {coiList.map((coi) => ( + + + {coi.assignment.project.title} + + + {coi.user.name || coi.user.email} + + + {coi.hasConflict ? ( + Yes + ) : ( + + No + + )} + + + {coi.hasConflict ? getConflictTypeBadge(coi.conflictType) : '-'} + + + {coi.description ? ( + + {coi.description} + + ) : ( + - + )} + + + {coi.hasConflict ? ( +
+ {getReviewBadge(coi.reviewAction)} + {coi.reviewedBy && ( +

+ by {coi.reviewedBy.name || coi.reviewedBy.email} + {coi.reviewedAt && ( + <> {formatDistanceToNow(new Date(coi.reviewedAt), { addSuffix: true })} + )} +

+ )} +
+ ) : ( + N/A + )} +
+ + {coi.hasConflict && ( + + + + + + + reviewCOI.mutate({ + id: coi.id, + reviewAction: 'cleared', + }) + } + > + + Clear + + + reviewCOI.mutate({ + id: coi.id, + reviewAction: 'reassigned', + }) + } + > + + Reassign + + + reviewCOI.mutate({ + id: coi.id, + reviewAction: 'noted', + }) + } + > + + Note + + + + )} + +
+ ))} +
+
+ + ) : ( +
+ +

No Declarations Yet

+

+ {conflictsOnly + ? 'No conflicts of interest have been declared for this round' + : 'No jury members have submitted COI declarations for this round yet'} +

+
+ )} + + + + ) +} + +function COISkeleton() { + return ( +
+ + +
+ + +
+ +
+ {[1, 2, 3].map((i) => ( + + + + + + + + + ))} +
+ + + + + + + + + + +
+ ) +} + +export default function COIManagementPage({ params }: PageProps) { + const { id } = use(params) + + return ( + }> + + + ) +} diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx index 07b99de..297048a 100644 --- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx @@ -418,7 +418,45 @@ function EditRoundContent({ roundId }: { roundId: string }) { - {/* Team Notification - removed from schema, feature not implemented */} + {/* Upload Deadline Policy */} + + + Upload Deadline Policy + + Control how file uploads are handled after the round starts + + + + +

+ When set to “Block”, applicants cannot upload files after the voting start date. + When set to “Allow late”, uploads are accepted but flagged as late submissions. +

+
+
{/* Evaluation Criteria */} 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 9502aa7..7c6608f 100644 --- a/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx @@ -53,6 +53,7 @@ import { RotateCcw, Loader2, ShieldCheck, + Download, } from 'lucide-react' import { cn } from '@/lib/utils' @@ -109,6 +110,41 @@ export default function FilteringResultsPage({ const overrideResult = trpc.filtering.overrideResult.useMutation() const reinstateProject = trpc.filtering.reinstateProject.useMutation() + const exportResults = trpc.export.filteringResults.useQuery( + { roundId }, + { enabled: false } + ) + + const handleExport = async () => { + const result = await exportResults.refetch() + if (result.data) { + const { data: rows, columns } = result.data + + const csvContent = [ + columns.join(','), + ...rows.map((row) => + columns + .map((col) => { + const value = row[col as keyof typeof row] + if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) { + return `"${value.replace(/"/g, '""')}"` + } + return value ?? '' + }) + .join(',') + ), + ].join('\n') + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `filtering-results-${new Date().toISOString().split('T')[0]}.csv` + link.click() + URL.revokeObjectURL(url) + } + } + const toggleRow = (id: string) => { const next = new Set(expandedRows) if (next.has(id)) next.delete(id) @@ -166,13 +202,27 @@ export default function FilteringResultsPage({ -
-

- Filtering Results -

-

- Review and override filtering outcomes -

+
+
+

+ Filtering Results +

+

+ Review and override filtering outcomes +

+
+
{/* Outcome Filter Tabs */} diff --git a/src/app/(admin)/admin/rounds/[id]/page.tsx b/src/app/(admin)/admin/rounds/[id]/page.tsx index e39ce45..4061699 100644 --- a/src/app/(admin)/admin/rounds/[id]/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/page.tsx @@ -50,6 +50,7 @@ import { AlertTriangle, ListChecks, ClipboardCheck, + Sparkles, } from 'lucide-react' import { toast } from 'sonner' import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog' @@ -125,6 +126,22 @@ function RoundDetailContent({ roundId }: { roundId: string }) { const startJob = trpc.filtering.startJob.useMutation() const finalizeResults = trpc.filtering.finalizeResults.useMutation() + // AI summary bulk generation + const bulkSummaries = trpc.evaluation.generateBulkSummaries.useMutation({ + onSuccess: (data) => { + if (data.errors.length > 0) { + toast.warning( + `Generated ${data.generated} of ${data.total} summaries. ${data.errors.length} failed.` + ) + } else { + toast.success(`Generated ${data.generated} AI summaries successfully`) + } + }, + onError: (error) => { + toast.error(error.message || 'Failed to generate AI summaries') + }, + }) + // Set active job from latest job on load useEffect(() => { if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) { @@ -764,6 +781,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) { Jury Assignments +
diff --git a/src/app/(jury)/jury/page.tsx b/src/app/(jury)/jury/page.tsx index 0df7025..64c579a 100644 --- a/src/app/(jury)/jury/page.tsx +++ b/src/app/(jury)/jury/page.tsx @@ -25,6 +25,7 @@ import { ArrowRight, } from 'lucide-react' import { formatDateOnly } from '@/lib/utils' +import { CountdownTimer } from '@/components/shared/countdown-timer' async function JuryDashboardContent() { const session = await auth() @@ -105,6 +106,27 @@ async function JuryDashboardContent() { {} as Record ) + // Get grace periods for this user + const gracePeriods = await prisma.gracePeriod.findMany({ + where: { + userId, + extendedUntil: { gte: new Date() }, + }, + select: { + roundId: true, + extendedUntil: true, + }, + }) + + // Build a map of roundId -> latest extendedUntil + const graceByRound = new Map() + for (const gp of gracePeriods) { + const existing = graceByRound.get(gp.roundId) + if (!existing || gp.extendedUntil > existing) { + graceByRound.set(gp.roundId, gp.extendedUntil) + } + } + // Get active rounds (voting window is open) const now = new Date() const activeRounds = Object.values(assignmentsByRound).filter( @@ -221,9 +243,15 @@ async function JuryDashboardContent() { {round.votingEndAt && ( -

- Deadline: {formatDateOnly(round.votingEndAt)} -

+
+ + + ({formatDateOnly(round.votingEndAt)}) + +
)} + + + + {(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => ( +
+
+ {member.role === 'LEAD' ? ( + + ) : ( + + {member.user.name?.charAt(0).toUpperCase() || '?'} + + )} +
+
+

+ {member.user.name || member.user.email} +

+

+ {member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} +

+
+
+ ))} +
+
+ )} + + + + + {/* Documents Tab */} + Uploaded Documents @@ -201,6 +337,7 @@ export function SubmissionDetailClient() {
{project.files.map((file) => { const Icon = fileTypeIcons[file.fileType] || File + const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null } return (
-

{file.fileName}

+
+

{file.fileName}

+ {fileRecord.isLate && ( + + + Submitted late + + )} +

{fileTypeLabels[file.fileType] || file.fileType}

@@ -226,110 +371,34 @@ export function SubmissionDetailClient() { )} + - {/* Metadata */} - {project.metadataJson && Object.keys(project.metadataJson as Record).length > 0 && ( - - - Additional Information - - -
- {Object.entries(project.metadataJson as Record).map(([key, value]) => ( -
-
- {key.replace(/_/g, ' ')} -
-
{String(value)}
-
- ))} -
-
-
- )} -
- - {/* Sidebar */} -
- {/* Status timeline */} + {/* Mentor Tab */} + - Status Timeline + + + Mentor Communication + + + Chat with your assigned mentor + - { + await sendMessage.mutateAsync({ projectId, message }) + }} + isLoading={messagesLoading} + isSending={sendMessage.isPending} /> - - {/* Dates */} - - - Key Dates - - -
- Created - {new Date(project.createdAt).toLocaleDateString()} -
- {project.submittedAt && ( -
- Submitted - {new Date(project.submittedAt).toLocaleDateString()} -
- )} -
- Last Updated - {new Date(project.updatedAt).toLocaleDateString()} -
-
-
- - {/* Team Members */} - {'teamMembers' in project && project.teamMembers && ( - - -
- - - Team - - -
-
- - {(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => ( -
-
- {member.role === 'LEAD' ? ( - - ) : ( - - {member.user.name?.charAt(0).toUpperCase() || '?'} - - )} -
-
-

- {member.user.name || member.user.email} -

-

- {member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} -

-
-
- ))} -
-
- )} -
-
+ +
) } diff --git a/src/app/api/cron/reminders/route.ts b/src/app/api/cron/reminders/route.ts new file mode 100644 index 0000000..1548873 --- /dev/null +++ b/src/app/api/cron/reminders/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' +import { processEvaluationReminders } from '@/server/services/evaluation-reminders' + +export async function GET(request: NextRequest): Promise { + const cronSecret = request.headers.get('x-cron-secret') + + if (!cronSecret || cronSecret !== process.env.CRON_SECRET) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + try { + const result = await processEvaluationReminders() + + return NextResponse.json({ + ok: true, + sent: result.sent, + errors: result.errors, + timestamp: new Date().toISOString(), + }) + } catch (error) { + console.error('Cron reminder processing failed:', error) + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/components/admin/evaluation-summary-card.tsx b/src/components/admin/evaluation-summary-card.tsx new file mode 100644 index 0000000..d112b71 --- /dev/null +++ b/src/components/admin/evaluation-summary-card.tsx @@ -0,0 +1,335 @@ +'use client' + +import { useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { + Sparkles, + RefreshCw, + Loader2, + CheckCircle2, + AlertTriangle, + Clock, + Users, + Target, +} from 'lucide-react' +import { toast } from 'sonner' +import { formatDistanceToNow } from 'date-fns' + +interface EvaluationSummaryCardProps { + projectId: string + roundId: string +} + +interface ScoringPatterns { + averageGlobalScore: number | null + consensus: number + criterionAverages: Record + evaluatorCount: number +} + +interface ThemeItem { + theme: string + sentiment: 'positive' | 'negative' | 'mixed' + frequency: number +} + +interface SummaryJson { + overallAssessment: string + strengths: string[] + weaknesses: string[] + themes: ThemeItem[] + recommendation: string + scoringPatterns: ScoringPatterns +} + +const sentimentColors: Record = { + positive: { badge: 'default', bg: 'bg-green-500/10 text-green-700' }, + negative: { badge: 'destructive', bg: 'bg-red-500/10 text-red-700' }, + mixed: { badge: 'secondary', bg: 'bg-amber-500/10 text-amber-700' }, +} + +export function EvaluationSummaryCard({ + projectId, + roundId, +}: EvaluationSummaryCardProps) { + const [isGenerating, setIsGenerating] = useState(false) + + const { + data: summary, + isLoading, + refetch, + } = trpc.evaluation.getSummary.useQuery({ projectId, roundId }) + + const generateMutation = trpc.evaluation.generateSummary.useMutation({ + onSuccess: () => { + toast.success('AI summary generated successfully') + refetch() + setIsGenerating(false) + }, + onError: (error) => { + toast.error(error.message || 'Failed to generate summary') + setIsGenerating(false) + }, + }) + + const handleGenerate = () => { + setIsGenerating(true) + generateMutation.mutate({ projectId, roundId }) + } + + if (isLoading) { + return ( + + + + + + + + + + + ) + } + + // No summary exists yet + if (!summary) { + return ( + + + + + AI Evaluation Summary + + + Generate an AI-powered analysis of jury evaluations + + + +
+ +

+ No summary generated yet. Click below to analyze submitted evaluations. +

+ +
+
+
+ ) + } + + const summaryData = summary.summaryJson as unknown as SummaryJson + const patterns = summaryData.scoringPatterns + + return ( + + +
+
+ + + AI Evaluation Summary + + + + Generated {formatDistanceToNow(new Date(summary.generatedAt), { addSuffix: true })} + {' '}using {summary.model} + +
+ + + + + + + Regenerate Summary + + This will replace the existing AI summary with a new one. + This uses your OpenAI API quota. + + + + Cancel + + Regenerate + + + + +
+
+ + {/* Scoring Stats */} +
+
+ +
+

+ {patterns.averageGlobalScore !== null + ? patterns.averageGlobalScore.toFixed(1) + : '-'} +

+

Avg Score

+
+
+
+ +
+

+ {Math.round(patterns.consensus * 100)}% +

+

Consensus

+
+
+
+ +
+

{patterns.evaluatorCount}

+

Evaluators

+
+
+
+ + {/* Overall Assessment */} +
+

Overall Assessment

+

+ {summaryData.overallAssessment} +

+
+ + {/* Strengths & Weaknesses */} +
+ {summaryData.strengths.length > 0 && ( +
+

Strengths

+
    + {summaryData.strengths.map((s, i) => ( +
  • + + {s} +
  • + ))} +
+
+ )} + {summaryData.weaknesses.length > 0 && ( +
+

Weaknesses

+
    + {summaryData.weaknesses.map((w, i) => ( +
  • + + {w} +
  • + ))} +
+
+ )} +
+ + {/* Themes */} + {summaryData.themes.length > 0 && ( +
+

Key Themes

+
+ {summaryData.themes.map((theme, i) => ( +
+
+ + {theme.sentiment} + + {theme.theme} +
+ + {theme.frequency} mention{theme.frequency !== 1 ? 's' : ''} + +
+ ))} +
+
+ )} + + {/* Criterion Averages */} + {Object.keys(patterns.criterionAverages).length > 0 && ( +
+

Criterion Averages

+
+ {Object.entries(patterns.criterionAverages).map(([label, avg]) => ( +
+ + {label} + +
+
+
+
+ + {avg.toFixed(1)} + +
+
+ ))} +
+
+ )} + + {/* Recommendation */} + {summaryData.recommendation && ( +
+

+ Recommendation +

+

+ {summaryData.recommendation} +

+
+ )} + + + ) +} diff --git a/src/components/forms/coi-declaration-dialog.tsx b/src/components/forms/coi-declaration-dialog.tsx new file mode 100644 index 0000000..37266ca --- /dev/null +++ b/src/components/forms/coi-declaration-dialog.tsx @@ -0,0 +1,162 @@ +'use client' + +import { useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Loader2, ShieldAlert } from 'lucide-react' +import { toast } from 'sonner' + +interface COIDeclarationDialogProps { + open: boolean + assignmentId: string + projectTitle: string + onComplete: (hasConflict: boolean) => void +} + +export function COIDeclarationDialog({ + open, + assignmentId, + projectTitle, + onComplete, +}: COIDeclarationDialogProps) { + const [hasConflict, setHasConflict] = useState(null) + const [conflictType, setConflictType] = useState('') + const [description, setDescription] = useState('') + + const declareCOI = trpc.evaluation.declareCOI.useMutation({ + onSuccess: (data) => { + if (data.hasConflict) { + toast.info('Conflict of interest recorded. An admin will review your declaration.') + } + onComplete(data.hasConflict) + }, + onError: (error) => { + toast.error(error.message || 'Failed to submit COI declaration') + }, + }) + + const handleSubmit = () => { + if (hasConflict === null) return + + declareCOI.mutate({ + assignmentId, + hasConflict, + conflictType: hasConflict ? conflictType : undefined, + description: hasConflict ? description : undefined, + }) + } + + const canSubmit = + hasConflict !== null && + (!hasConflict || (hasConflict && conflictType)) && + !declareCOI.isPending + + return ( + + + + + + Conflict of Interest Declaration + + + Before evaluating “{projectTitle}”, please declare whether + you have any conflict of interest with this project. + + + +
+
+ +
+ + +
+
+ + {hasConflict && ( + <> +
+ + +
+ +
+ +