Compare commits

...

3 Commits

Author SHA1 Message Date
Matt 699248e40b Implement 10 platform features: evaluation UX, admin tools, AI summaries, applicant portal
Build and Push Docker Image / build (push) Failing after 5m5s Details
Batch 1 - Quick Wins:
- F1: Evaluation progress indicator with touch tracking in sticky status bar
- F2: Export filtering results as CSV with dynamic AI column flattening
- F3: Observer access to analytics dashboards (8 procedures changed to observerProcedure)

Batch 2 - Jury Experience:
- F4: Countdown timer component with urgency colors + email reminder service with cron endpoint
- F5: Conflict of interest declaration system (dialog, admin management, review workflow)

Batch 3 - Admin & AI Enhancements:
- F6: Bulk status update UI with selection checkboxes, floating toolbar, status history recording
- F7: AI-powered evaluation summary with anonymized data, OpenAI integration, scoring patterns
- F8: Smart assignment improvements (geo diversity penalty, round familiarity bonus, COI blocking)

Batch 4 - Form Flexibility & Applicant Portal:
- F9: Evaluation form flexibility (text, boolean, section_header types, conditional visibility)
- F10: Applicant portal (status timeline, per-round documents, mentor messaging)

Schema: 5 new models (ReminderLog, ConflictOfInterest, EvaluationSummary, ProjectStatusHistory, MentorMessage), ProjectFile extended with roundId + isLate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:58:27 +01:00
Matt 002a9dbfc3 Platform review round 2: audit logging migration, nav unification, DB indexes, and UI polish
- Migrate ~41 inline audit log calls to shared logAudit() utility across all routers
- Add transaction-aware prisma parameter to logAudit() for atomic operations
- Unify jury/mentor/observer navigation into shared RoleNav component
- Add composite DB indexes (Evaluation, GracePeriod, AuditLog) for query performance
- Fix profile page: consolidate dual save buttons, proper useEffect initialization
- Enhance auth error page with MOPC branding and navigation
- Improve observer dashboard with prominent read-only badge
- Fix DI-3: fetch projects before bulk status update for accurate notifications
- Remove unused aiBoost field from smart-assignment scoring
- Add shared image-upload utility and structured logger module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 21:09:06 +01:00
Matt 8d0979e649 Comprehensive platform review: security fixes, query optimization, UI improvements, and code cleanup
Security (Critical/High):
- Fix path traversal bypass in local storage provider (path.resolve + prefix check)
- Fix timing-unsafe HMAC comparison (crypto.timingSafeEqual)
- Add auth + ownership checks to email API routes (verify-credentials, change-password)
- Remove hardcoded secret key fallback in local storage provider
- Add production credential check for MinIO (fail loudly if not set)
- Remove DB error details from health check response
- Add stricter rate limiting on application submissions (5/hour)
- Add rate limiting on email availability check (anti-enumeration)
- Change getAIAssignmentJobStatus to adminProcedure
- Block dangerous file extensions on upload
- Reduce project list max perPage from 5000 to 200

Query Optimization:
- Optimize analytics getProjectRankings with select instead of full includes
- Fix N+1 in mentor.getSuggestions (batch findMany instead of loop)
- Use _count for files instead of fetching full file records in project list
- Switch to bulk notifications in assignment and user bulk operations
- Batch filtering upserts (25 per transaction instead of all at once)

UI/UX:
- Replace Inter font with Montserrat in public layout (brand consistency)
- Use Logo component in public layout instead of placeholder
- Create branded 404 and error pages
- Make admin rounds table responsive with mobile card layout
- Fix notification bell paths to be role-aware
- Replace hardcoded slate colors with semantic tokens in admin sidebar
- Force light mode (dark mode untested)
- Adjust CardTitle default size
- Improve muted-foreground contrast for accessibility (A11Y)
- Move profile form state initialization to useEffect

Code Quality:
- Extract shared toProjectWithRelations to anonymization.ts (removed 3 duplicates)
- Remove dead code: getObjectInfo, isValidImageSize, unused batch tag functions, debug logs
- Remove unused twilio dependency
- Remove redundant email index from schema
- Add actual storage object deletion when file records are deleted
- Wrap evaluation submit + assignment update in
- Add comprehensive platform review document

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-05 20:31:08 +01:00
83 changed files with 9484 additions and 2941 deletions

View File

@ -22,8 +22,11 @@ The platform is designed for future expansion into a comprehensive program manag
| First Admin | Database seed script | | First Admin | Database seed script |
| Past Evaluations | Visible read-only after submit | | Past Evaluations | Visible read-only after submit |
| Grace Period | Admin-configurable per juror/project | | 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 | | 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 ## Brand Identity
@ -83,14 +86,22 @@ mopc-platform/
│ │ ├── (admin)/ # Admin dashboard (protected) │ │ ├── (admin)/ # Admin dashboard (protected)
│ │ ├── (jury)/ # Jury interface (protected) │ │ ├── (jury)/ # Jury interface (protected)
│ │ ├── api/ # API routes │ │ ├── api/ # API routes
│ │ │ └── trpc/ # tRPC endpoint │ │ │ ├── trpc/ # tRPC endpoint
│ │ │ └── cron/
│ │ │ └── reminders/ # Cron endpoint for evaluation reminders (F4)
│ │ ├── layout.tsx # Root layout │ │ ├── layout.tsx # Root layout
│ │ └── page.tsx # Home/landing │ │ └── page.tsx # Home/landing
│ ├── components/ │ ├── components/
│ │ ├── ui/ # shadcn/ui 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) │ │ ├── layouts/ # Layout components (sidebar, nav)
│ │ └── shared/ # Shared components │ │ └── shared/ # Shared components
│ │ └── countdown-timer.tsx # Live countdown with urgency (F4)
│ ├── lib/ │ ├── lib/
│ │ ├── auth.ts # NextAuth configuration │ │ ├── auth.ts # NextAuth configuration
│ │ ├── prisma.ts # Prisma client singleton │ │ ├── prisma.ts # Prisma client singleton
@ -107,8 +118,14 @@ mopc-platform/
│ │ │ ├── evaluation.ts │ │ │ ├── evaluation.ts
│ │ │ ├── audit.ts │ │ │ ├── audit.ts
│ │ │ ├── settings.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 │ │ ├── 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 │ │ └── middleware/ # RBAC & auth middleware
│ ├── hooks/ # React hooks │ ├── hooks/ # React hooks
│ ├── types/ # Shared TypeScript types │ ├── types/ # Shared TypeScript types
@ -240,8 +257,11 @@ SMTP_USER="noreply@monaco-opc.com"
SMTP_PASS="your-smtp-password" SMTP_PASS="your-smtp-password"
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>" EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
# OpenAI (for smart assignment) # OpenAI (for smart assignment and AI evaluation summaries)
OPENAI_API_KEY="your-openai-api-key" OPENAI_API_KEY="your-openai-api-key"
# Cron (for scheduled evaluation reminders)
CRON_SECRET="your-cron-secret-key"
``` ```
## Key Architectural Decisions ## Key Architectural Decisions
@ -273,8 +293,10 @@ OPENAI_API_KEY="your-openai-api-key"
|------|------------| |------|------------|
| **SUPER_ADMIN** | Full system access, all programs, user management | | **SUPER_ADMIN** | Full system access, all programs, user management |
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury | | **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
| **JURY_MEMBER** | View assigned projects only, submit evaluations | | **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI |
| **OBSERVER** | Read-only access to dashboards (optional) | | **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 ## Important Constraints
@ -286,6 +308,12 @@ OPENAI_API_KEY="your-openai-api-key"
6. **Mobile responsiveness is mandatory** - every view must work on phones 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 7. **File downloads require project authorization** - jury/mentor must be assigned to the project
8. **Mentor endpoints require MENTOR role** - uses `mentorProcedure` middleware 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 ## Security Notes
@ -326,9 +354,18 @@ The MOPC platform connects to these via environment variables.
- Progress dashboards - Progress dashboards
- CSV export - CSV export
- Audit logging - 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+) ### Out of Scope (Phase 2+)
- Auto-assignment algorithm
- Typeform/Notion integrations - Typeform/Notion integrations
- WhatsApp notifications - WhatsApp notifications
- Learning hub - Learning hub

View File

@ -25,7 +25,13 @@ RUN npx prisma generate
COPY . . COPY . .
# Build the application at build time (pre-compile everything) # 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 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 port
EXPOSE 3000 EXPOSE 3000

View File

@ -65,16 +65,25 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty
│ PRESENTATION LAYER │ │ PRESENTATION LAYER │
├─────────────────────────────────────────────────────────────────────────────┤ ├─────────────────────────────────────────────────────────────────────────────┤
│ │ │ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Admin Views │ │ Jury Views │ │ Auth Views │ │ │ │ Admin Views │ │ Jury Views │ │Applicant View│ │ Mentor Views │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ - Dashboard │ │ - Project List │ │ - Login │ │ │ │ - Dashboard │ │ - Project Ls │ │ - Status │ │ - Assigned │ │
│ │ - Rounds │ │ - Project View │ │ - Magic Link │ │ │ │ - Rounds │ │ - Project Vw │ │ Tracker │ │ Projects │ │
│ │ - Projects │ │ - Evaluation │ │ - Verify │ │ │ │ - Projects │ │ - Evaluation │ │ - Document │ │ - Messaging │ │
│ │ - Jury Mgmt │ │ - My Progress │ │ │ │ │ │ - Jury Mgmt │ │ - My Progress│ │ Uploads │ │ │ │
│ │ - Assignments │ │ │ │ │ │ │ │ - Assignments│ │ - COI Decl. │ │ - Mentor │ │ │ │
│ │ - Reports │ │ │ │ │ │ │ │ - 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 │ │ 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 │ │ │ │ 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 | | Component | Responsibility |
|-----------|----------------| |-----------|----------------|
| **Admin Views** | Program/round management, project import, jury management, assignments, dashboards | | **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 | | **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 | | **Auth Views** | Login, magic link verification, session management |
| **Layouts** | Responsive navigation, sidebar, mobile adaptations | | **Layouts** | Responsive navigation, sidebar, mobile adaptations |
| **UI Components** | shadcn/ui based, reusable, accessible | | **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 | | **EvaluationService** | Form submission, autosave, scoring |
| **FileService** | MinIO uploads, pre-signed URLs | | **FileService** | MinIO uploads, pre-signed URLs |
| **EmailService** | Magic links, notifications via Nodemailer | | **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 | | **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 ### Data Layer
@ -345,8 +364,14 @@ The platform includes two assignment modes:
``` ```
Score = (expertise_match × 40) + (load_balance × 25) + Score = (expertise_match × 40) + (load_balance × 25) +
(specialty_match × 20) + (diversity × 10) - (conflict × 100) (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 ## Admin Settings Panel
Centralized configuration for: Centralized configuration for:
@ -363,8 +388,10 @@ Centralized configuration for:
|------|------------| |------|------------|
| **SUPER_ADMIN** | Full system access, all programs, user management | | **SUPER_ADMIN** | Full system access, all programs, user management |
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury | | **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
| **JURY_MEMBER** | View assigned projects only, submit evaluations | | **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI |
| **OBSERVER** | Read-only access to dashboards | | **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 ## Related Documentation

View File

@ -119,6 +119,9 @@ export const hasRole = (...roles: UserRole[]) =>
export const protectedProcedure = t.procedure.use(isAuthenticated) export const protectedProcedure = t.procedure.use(isAuthenticated)
export const adminProcedure = t.procedure.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN')) export const adminProcedure = t.procedure.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER')) 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 ## Router Structure
@ -136,7 +139,9 @@ src/server/routers/
├── export.ts # Export operations ├── export.ts # Export operations
├── audit.ts # Audit log access ├── audit.ts # Audit log access
├── settings.ts # Platform settings (admin) ├── 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 ### 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 ## Authentication Flow
### Magic Link Implementation ### Magic Link Implementation

View File

@ -112,6 +112,72 @@ The MOPC platform uses PostgreSQL as its primary database, accessed via Prisma O
│ grantedBy │ │ grantedBy │
│ createdAt │ │ 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 ## Prisma Schema
@ -137,6 +203,8 @@ enum UserRole {
PROGRAM_ADMIN PROGRAM_ADMIN
JURY_MEMBER JURY_MEMBER
OBSERVER OBSERVER
MENTOR
APPLICANT
} }
enum UserStatus { enum UserStatus {
@ -357,12 +425,14 @@ model Project {
model ProjectFile { model ProjectFile {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String projectId String
roundId String? // Per-round document management (Phase B)
// File info // File info
fileType FileType fileType FileType
fileName String fileName String
mimeType String mimeType String
size Int // bytes size Int // bytes
isLate Boolean @default(false) // Upload deadline policy tracking
// MinIO location // MinIO location
bucket String bucket String
@ -376,6 +446,7 @@ model ProjectFile {
// Indexes // Indexes
@@index([projectId]) @@index([projectId])
@@index([fileType]) @@index([fileType])
@@index([roundId])
@@unique([bucket, objectKey]) @@unique([bucket, objectKey])
} }
@ -475,6 +546,119 @@ model AuditLog {
@@index([entityType, entityId]) @@index([entityType, entityId])
@@index([timestamp]) @@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 ## Indexing Strategy
@ -505,6 +689,22 @@ model AuditLog {
| Evaluation | `submittedAt` | Sort by submission time | | Evaluation | `submittedAt` | Sort by submission time |
| AuditLog | `timestamp` | Time-based queries | | AuditLog | `timestamp` | Time-based queries |
| AuditLog | `entityType, entityId` | Entity history | | 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 ### JSON Field Indexes

1836
docs/platform-review.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -56,7 +56,6 @@
"@trpc/client": "^11.0.0-rc.678", "@trpc/client": "^11.0.0-rc.678",
"@trpc/react-query": "^11.0.0-rc.678", "@trpc/react-query": "^11.0.0-rc.678",
"@trpc/server": "^11.0.0-rc.678", "@trpc/server": "^11.0.0-rc.678",
"@types/leaflet": "^1.9.21",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -82,19 +81,18 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"superjson": "^2.2.2", "superjson": "^2.2.2",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"twilio": "^5.4.0",
"use-debounce": "^10.0.4", "use-debounce": "^10.0.4",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/nodemailer": "^7.0.9", "@types/nodemailer": "^7.0.9",
"@types/papaparse": "^5.3.15", "@types/papaparse": "^5.3.15",
"@types/react": "^19.0.2", "@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2", "@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-next": "^15.1.0", "eslint-config-next": "^15.1.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",

View File

@ -250,16 +250,29 @@ model User {
// Award overrides // Award overrides
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy") awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
// In-app notifications // In-app notifications
notifications InAppNotification[] @relation("UserNotifications") notifications InAppNotification[] @relation("UserNotifications")
notificationSettingsUpdated NotificationEmailSetting[] @relation("NotificationSettingUpdater") 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 // NextAuth relations
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
@@index([email])
@@index([role]) @@index([role])
@@index([status]) @@index([status])
} }
@ -377,6 +390,8 @@ model Round {
filteringJobs FilteringJob[] filteringJobs FilteringJob[]
assignmentJobs AssignmentJob[] assignmentJobs AssignmentJob[]
taggingJobs TaggingJob[] taggingJobs TaggingJob[]
reminderLogs ReminderLog[]
projectFiles ProjectFile[]
@@index([programId]) @@index([programId])
@@index([status]) @@index([status])
@ -480,6 +495,9 @@ model Project {
awardVotes AwardVote[] awardVotes AwardVote[]
wonAwards SpecialAward[] @relation("AwardWinner") wonAwards SpecialAward[] @relation("AwardWinner")
projectTags ProjectTag[] projectTags ProjectTag[]
statusHistory ProjectStatusHistory[]
mentorMessages MentorMessage[]
evaluationSummaries EvaluationSummary[]
@@index([roundId]) @@index([roundId])
@@index([status]) @@index([status])
@ -494,6 +512,7 @@ model Project {
model ProjectFile { model ProjectFile {
id String @id @default(cuid()) id String @id @default(cuid())
projectId String projectId String
roundId String? // Which round this file was submitted for
// File info // File info
fileType FileType fileType FileType
@ -505,13 +524,17 @@ model ProjectFile {
bucket String bucket String
objectKey String objectKey String
isLate Boolean @default(false) // Uploaded after round deadline
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Relations // Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round? @relation(fields: [roundId], references: [id])
@@unique([bucket, objectKey]) @@unique([bucket, objectKey])
@@index([projectId]) @@index([projectId])
@@index([roundId])
@@index([fileType]) @@index([fileType])
} }
@ -539,10 +562,11 @@ model Assignment {
createdBy String? // Admin who created the assignment createdBy String? // Admin who created the assignment
// Relations // Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
evaluation Evaluation? evaluation Evaluation?
conflictOfInterest ConflictOfInterest?
@@unique([userId, projectId, roundId]) @@unique([userId, projectId, roundId])
@@index([userId]) @@index([userId])
@ -566,7 +590,9 @@ model Evaluation {
binaryDecision Boolean? // Yes/No for semi-finalist binaryDecision Boolean? // Yes/No for semi-finalist
feedbackText String? @db.Text feedbackText String? @db.Text
// Versioning // Versioning (currently unused - evaluations are updated in-place.
// TODO: Implement proper versioning by creating new rows on re-submission
// if version history is needed for audit purposes)
version Int @default(1) version Int @default(1)
// Timestamps // Timestamps
@ -581,6 +607,7 @@ model Evaluation {
@@index([status]) @@index([status])
@@index([submittedAt]) @@index([submittedAt])
@@index([formId]) @@index([formId])
@@index([status, formId])
} }
// ============================================================================= // =============================================================================
@ -609,6 +636,7 @@ model GracePeriod {
@@index([extendedUntil]) @@index([extendedUntil])
@@index([grantedById]) @@index([grantedById])
@@index([projectId]) @@index([projectId])
@@index([roundId, userId, extendedUntil])
} }
// ============================================================================= // =============================================================================
@ -660,6 +688,7 @@ model AuditLog {
@@index([action]) @@index([action])
@@index([entityType, entityId]) @@index([entityType, entityId])
@@index([timestamp]) @@index([timestamp])
@@index([entityType, entityId, timestamp])
} }
// ============================================================================= // =============================================================================
@ -1212,7 +1241,7 @@ model SpecialAward {
// Winner // Winner
winnerProjectId String? winnerProjectId String?
winnerOverridden Boolean @default(false) winnerOverridden Boolean @default(false)
winnerOverriddenBy String? winnerOverriddenBy String? // FK to User who overrode the winner
sortOrder Int @default(0) sortOrder Int @default(0)
@ -1222,6 +1251,7 @@ model SpecialAward {
// Relations // Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade) program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
winnerProject Project? @relation("AwardWinner", fields: [winnerProjectId], references: [id], onDelete: SetNull) winnerProject Project? @relation("AwardWinner", fields: [winnerProjectId], references: [id], onDelete: SetNull)
overriddenByUser User? @relation("AwardOverriddenBy", fields: [winnerOverriddenBy], references: [id], onDelete: SetNull)
eligibilities AwardEligibility[] eligibilities AwardEligibility[]
jurors AwardJuror[] jurors AwardJuror[]
votes AwardVote[] votes AwardVote[]
@ -1291,3 +1321,109 @@ model AwardVote {
@@index([userId]) @@index([userId])
@@index([projectId]) @@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])
}

View File

@ -27,6 +27,7 @@ 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'
import { UserAvatar } from '@/components/shared/user-avatar' import { UserAvatar } from '@/components/shared/user-avatar'
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
import { import {
ArrowLeft, ArrowLeft,
Edit, Edit,
@ -635,6 +636,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* AI Evaluation Summary */}
{project.roundId && stats && stats.totalEvaluations > 0 && (
<EvaluationSummaryCard
projectId={projectId}
roundId={project.roundId}
/>
)}
</div> </div>
) )
} }

View File

@ -67,6 +67,8 @@ import {
AlertCircle, AlertCircle,
Layers, Layers,
FolderOpen, FolderOpen,
X,
AlertTriangle,
} from 'lucide-react' } from 'lucide-react'
import { import {
Select, Select,
@ -75,6 +77,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { truncate } from '@/lib/utils' import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo' import { ProjectLogo } from '@/components/shared/project-logo'
@ -346,6 +349,77 @@ export default function ProjectsPage() {
? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100) ? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100)
: 0 : 0
// Bulk selection state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [bulkStatus, setBulkStatus] = useState<string>('')
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({ const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success('Project deleted successfully') toast.success('Project deleted successfully')
@ -468,6 +542,15 @@ export default function ProjectsPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
{filters.roundId && (
<TableHead className="w-10">
<Checkbox
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
onCheckedChange={handleSelectAll}
aria-label="Select all projects"
/>
</TableHead>
)}
<TableHead>Project</TableHead> <TableHead>Project</TableHead>
<TableHead>Round</TableHead> <TableHead>Round</TableHead>
<TableHead>Files</TableHead> <TableHead>Files</TableHead>
@ -484,6 +567,16 @@ export default function ProjectsPage() {
key={project.id} key={project.id}
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`} className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
> >
{filters.roundId && (
<TableCell className="relative z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
)}
<TableCell> <TableCell>
<Link <Link
href={`/admin/projects/${project.id}`} href={`/admin/projects/${project.id}`}
@ -519,7 +612,7 @@ export default function ProjectsPage() {
</p> </p>
</div> </div>
</TableCell> </TableCell>
<TableCell>{project.files?.length ?? 0}</TableCell> <TableCell>{project._count?.files ?? 0}</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
@ -577,14 +670,23 @@ export default function ProjectsPage() {
{/* Mobile card view */} {/* Mobile card view */}
<div className="space-y-4 md:hidden"> <div className="space-y-4 md:hidden">
{data.projects.map((project) => ( {data.projects.map((project) => (
<div key={project.id} className="relative">
{filters.roundId && (
<div className="absolute left-3 top-4 z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
/>
</div>
)}
<Link <Link
key={project.id}
href={`/admin/projects/${project.id}`} href={`/admin/projects/${project.id}`}
className="block" className="block"
> >
<Card className="transition-colors hover:bg-muted/50"> <Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start gap-3"> <div className={`flex items-start gap-3 ${filters.roundId ? 'pl-8' : ''}`}>
<ProjectLogo <ProjectLogo
project={project} project={project}
size="md" size="md"
@ -627,6 +729,7 @@ export default function ProjectsPage() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
</div>
))} ))}
</div> </div>
@ -641,6 +744,93 @@ export default function ProjectsPage() {
</> </>
) : null} ) : null}
{/* Bulk Action Floating Toolbar */}
{selectedIds.size > 0 && filters.roundId && (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2">
<Card className="border-2 shadow-lg">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<Badge variant="secondary" className="shrink-0 text-sm">
{selectedIds.size} selected
</Badge>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Set status..." />
</SelectTrigger>
<SelectContent>
{['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].map((s) => (
<SelectItem key={s} value={s}>
{s.replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleBulkApply}
disabled={!bulkStatus || bulkUpdateStatus.isPending}
>
{bulkUpdateStatus.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Apply
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedIds(new Set())
setBulkStatus('')
}}
>
<X className="mr-1 h-4 w-4" />
Clear
</Button>
</div>
</CardContent>
</Card>
</div>
)}
{/* Bulk Status Update Confirmation Dialog */}
<AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update Project Status</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
You are about to change the status of{' '}
<strong>{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}</strong>{' '}
to <Badge variant={statusColors[bulkStatus] || 'secondary'}>{bulkStatus.replace('_', ' ')}</Badge>.
</p>
{bulkStatus === 'REJECTED' && (
<div className="flex items-start gap-2 rounded-md bg-destructive/10 p-3 text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm">
Warning: Rejected projects will be marked as eliminated. This will send notifications to the project teams.
</p>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkUpdateStatus.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleBulkConfirm}
className={bulkStatus === 'REJECTED' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
disabled={bulkUpdateStatus.isPending}
>
{bulkUpdateStatus.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete Confirmation Dialog */} {/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>

View File

@ -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 <COISkeleton />
}
if (!round) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Round Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds">Back to Rounds</Link>
</Button>
</CardContent>
</Card>
)
}
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 (
<Badge variant="default" className="bg-green-600">
<ShieldCheck className="mr-1 h-3 w-3" />
Cleared
</Badge>
)
case 'reassigned':
return (
<Badge variant="default" className="bg-blue-600">
<UserX className="mr-1 h-3 w-3" />
Reassigned
</Badge>
)
case 'noted':
return (
<Badge variant="secondary">
<StickyNote className="mr-1 h-3 w-3" />
Noted
</Badge>
)
default:
return (
<Badge variant="outline" className="text-amber-600 border-amber-300">
Pending Review
</Badge>
)
}
}
const getConflictTypeBadge = (type: string | null) => {
switch (type) {
case 'financial':
return <Badge variant="destructive">Financial</Badge>
case 'personal':
return <Badge variant="secondary">Personal</Badge>
case 'organizational':
return <Badge variant="outline">Organizational</Badge>
case 'other':
return <Badge variant="outline">Other</Badge>
default:
return null
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
{round.program.name}
</Link>
<span>/</span>
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
{round.name}
</Link>
</div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<ShieldAlert className="h-6 w-6" />
Conflict of Interest Declarations
</h1>
</div>
{/* Stats */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Declarations</CardTitle>
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Conflicts Declared</CardTitle>
<AlertCircle className="h-4 w-4 text-amber-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-amber-600">{conflictCount}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Reviewed</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{reviewedCount}</div>
</CardContent>
</Card>
</div>
{/* COI Table */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Declarations</CardTitle>
<CardDescription>
Review and manage conflict of interest declarations from jury members
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Switch
id="conflicts-only"
checked={conflictsOnly}
onCheckedChange={setConflictsOnly}
/>
<Label htmlFor="conflicts-only" className="text-sm">
Conflicts only
</Label>
</div>
</div>
</CardHeader>
<CardContent>
{coiList && coiList.length > 0 ? (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Juror</TableHead>
<TableHead>Conflict</TableHead>
<TableHead>Type</TableHead>
<TableHead>Description</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-12">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{coiList.map((coi) => (
<TableRow key={coi.id}>
<TableCell className="font-medium max-w-[200px] truncate">
{coi.assignment.project.title}
</TableCell>
<TableCell>
{coi.user.name || coi.user.email}
</TableCell>
<TableCell>
{coi.hasConflict ? (
<Badge variant="destructive">Yes</Badge>
) : (
<Badge variant="outline" className="text-green-600 border-green-300">
No
</Badge>
)}
</TableCell>
<TableCell>
{coi.hasConflict ? getConflictTypeBadge(coi.conflictType) : '-'}
</TableCell>
<TableCell className="max-w-[200px]">
{coi.description ? (
<span className="text-sm text-muted-foreground truncate block">
{coi.description}
</span>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{coi.hasConflict ? (
<div className="space-y-1">
{getReviewBadge(coi.reviewAction)}
{coi.reviewedBy && (
<p className="text-xs text-muted-foreground">
by {coi.reviewedBy.name || coi.reviewedBy.email}
{coi.reviewedAt && (
<> {formatDistanceToNow(new Date(coi.reviewedAt), { addSuffix: true })}</>
)}
</p>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">N/A</span>
)}
</TableCell>
<TableCell>
{coi.hasConflict && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={reviewCOI.isPending}
>
{reviewCOI.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'cleared',
})
}
>
<ShieldCheck className="mr-2 h-4 w-4" />
Clear
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'reassigned',
})
}
>
<UserX className="mr-2 h-4 w-4" />
Reassign
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
reviewCOI.mutate({
id: coi.id,
reviewAction: 'noted',
})
}
>
<StickyNote className="mr-2 h-4 w-4" />
Note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<ShieldAlert className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Declarations Yet</p>
<p className="text-sm text-muted-foreground">
{conflictsOnly
? 'No conflicts of interest have been declared for this round'
: 'No jury members have submitted COI declarations for this round yet'}
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function COISkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-80" />
</div>
<div className="grid gap-4 sm:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<Skeleton className="h-48 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function COIManagementPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<COISkeleton />}>
<COIManagementContent roundId={id} />
</Suspense>
)
}

View File

@ -418,7 +418,45 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</CardContent> </CardContent>
</Card> </Card>
{/* Team Notification - removed from schema, feature not implemented */} {/* Upload Deadline Policy */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Upload Deadline Policy</CardTitle>
<CardDescription>
Control how file uploads are handled after the round starts
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Select
value={(roundSettings.uploadDeadlinePolicy as string) || ''}
onValueChange={(value) =>
setRoundSettings((prev) => ({
...prev,
uploadDeadlinePolicy: value || undefined,
}))
}
>
<SelectTrigger>
<SelectValue placeholder="Default (no restriction)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">
Default - No restriction
</SelectItem>
<SelectItem value="BLOCK">
Block uploads after round starts
</SelectItem>
<SelectItem value="ALLOW_LATE">
Allow late uploads (marked as late)
</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
When set to &ldquo;Block&rdquo;, applicants cannot upload files after the voting start date.
When set to &ldquo;Allow late&rdquo;, uploads are accepted but flagged as late submissions.
</p>
</CardContent>
</Card>
{/* Evaluation Criteria */} {/* Evaluation Criteria */}
<Card> <Card>

View File

@ -53,6 +53,7 @@ import {
RotateCcw, RotateCcw,
Loader2, Loader2,
ShieldCheck, ShieldCheck,
Download,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -109,6 +110,41 @@ export default function FilteringResultsPage({
const overrideResult = trpc.filtering.overrideResult.useMutation() const overrideResult = trpc.filtering.overrideResult.useMutation()
const reinstateProject = trpc.filtering.reinstateProject.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 toggleRow = (id: string) => {
const next = new Set(expandedRows) const next = new Set(expandedRows)
if (next.has(id)) next.delete(id) if (next.has(id)) next.delete(id)
@ -166,13 +202,27 @@ export default function FilteringResultsPage({
</Button> </Button>
</div> </div>
<div> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold tracking-tight"> <div>
Filtering Results <h1 className="text-2xl font-semibold tracking-tight">
</h1> Filtering Results
<p className="text-muted-foreground"> </h1>
Review and override filtering outcomes <p className="text-muted-foreground">
</p> Review and override filtering outcomes
</p>
</div>
<Button
variant="outline"
onClick={handleExport}
disabled={exportResults.isFetching}
>
{exportResults.isFetching ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Export CSV
</Button>
</div> </div>
{/* Outcome Filter Tabs */} {/* Outcome Filter Tabs */}

View File

@ -50,6 +50,7 @@ import {
AlertTriangle, AlertTriangle,
ListChecks, ListChecks,
ClipboardCheck, ClipboardCheck,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog' import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
@ -125,6 +126,22 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const startJob = trpc.filtering.startJob.useMutation() const startJob = trpc.filtering.startJob.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.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 // Set active job from latest job on load
useEffect(() => { useEffect(() => {
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) { if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
@ -764,6 +781,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Jury Assignments Jury Assignments
</Link> </Link>
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => bulkSummaries.mutate({ roundId: round.id })}
disabled={bulkSummaries.isPending}
>
{bulkSummaries.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
</Button>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@ -186,8 +186,8 @@ function ProgramRounds({ program }: { program: any }) {
<CardContent> <CardContent>
{rounds.length > 0 ? ( {rounds.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{/* Header */} {/* Desktop: Table header */}
<div className="grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide"> <div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
<div>Order</div> <div>Order</div>
<div>Round</div> <div>Round</div>
<div>Status</div> <div>Status</div>
@ -207,7 +207,7 @@ function ProgramRounds({ program }: { program: any }) {
items={rounds.map((r) => r.id)} items={rounds.map((r) => r.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="space-y-1"> <div className="space-y-2 lg:space-y-1">
{rounds.map((round, index) => ( {rounds.map((round, index) => (
<SortableRoundRow <SortableRoundRow
key={round.id} key={round.id}
@ -378,157 +378,229 @@ function SortableRoundRow({
) )
} }
const actionsMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Round
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Manage Judge Assignments
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{round.status === 'DRAFT' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ACTIVE' })
}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Activate Round
</DropdownMenuItem>
)}
{round.status === 'ACTIVE' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'CLOSED' })
}
>
<Clock className="mr-2 h-4 w-4" />
Close Round
</DropdownMenuItem>
)}
{round.status === 'CLOSED' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ARCHIVED' })
}
>
<Archive className="mr-2 h-4 w-4" />
Archive Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Round
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
const deleteDialog = (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Round</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{round.name}&quot;? This will
remove {round._count?.projects || 0} project assignments,{' '}
{round._count?.assignments || 0} reviewer assignments, and all evaluations
in this round. The projects themselves will remain in the program. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteRound.mutate({ id: round.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteRound.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={cn( className={cn(
'grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5 rounded-lg border bg-card transition-all', 'rounded-lg border bg-card transition-all',
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90', isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
isReordering && !isDragging && 'opacity-50' isReordering && !isDragging && 'opacity-50'
)} )}
> >
{/* Order number with drag handle */} {/* Desktop: Table row layout */}
<div className="flex items-center gap-1"> <div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5">
<button {/* Order number with drag handle */}
{...attributes} <div className="flex items-center gap-1">
{...listeners} <button
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none" {...attributes}
disabled={isReordering} {...listeners}
> className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
<GripVertical className="h-4 w-4 text-muted-foreground" /> disabled={isReordering}
</button> >
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold"> <GripVertical className="h-4 w-4 text-muted-foreground" />
{index} </button>
</span> <span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
{index}
</span>
</div>
{/* Round name */}
<div>
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline"
>
{round.name}
</Link>
<p className="text-xs text-muted-foreground capitalize">
{round.roundType?.toLowerCase().replace('_', ' ')}
</p>
</div>
{/* Status */}
<div>{getStatusBadge()}</div>
{/* Voting window */}
<div>{getVotingWindow()}</div>
{/* Projects */}
<div className="flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.projects || 0}</span>
</div>
{/* Assignments */}
<div className="flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.assignments || 0}</span>
</div>
{/* Actions */}
<div>
{actionsMenu}
</div>
</div> </div>
{/* Round name */} {/* Mobile/Tablet: Card layout */}
<div> <div className="lg:hidden p-4">
<Link {/* Top row: drag handle, order, name, status badge, actions */}
href={`/admin/rounds/${round.id}`} <div className="flex items-start gap-3">
className="font-medium hover:underline" <div className="flex items-center gap-1 pt-0.5">
> <button
{round.name} {...attributes}
</Link> {...listeners}
<p className="text-xs text-muted-foreground capitalize"> className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
{round.roundType?.toLowerCase().replace('_', ' ')} disabled={isReordering}
</p>
</div>
{/* Status */}
<div>{getStatusBadge()}</div>
{/* Voting window */}
<div>{getVotingWindow()}</div>
{/* Projects */}
<div className="flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.projects || 0}</span>
</div>
{/* Assignments */}
<div className="flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.assignments || 0}</span>
</div>
{/* Actions */}
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Round
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Manage Judge Assignments
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{round.status === 'DRAFT' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ACTIVE' })
}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Activate Round
</DropdownMenuItem>
)}
{round.status === 'ACTIVE' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'CLOSED' })
}
>
<Clock className="mr-2 h-4 w-4" />
Close Round
</DropdownMenuItem>
)}
{round.status === 'CLOSED' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ARCHIVED' })
}
>
<Archive className="mr-2 h-4 w-4" />
Archive Round
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setShowDeleteDialog(true)}
> >
<Trash2 className="mr-2 h-4 w-4" /> <GripVertical className="h-4 w-4 text-muted-foreground" />
Delete Round </button>
</DropdownMenuItem> <span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
</DropdownMenuContent> {index}
</DropdownMenu> </span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline line-clamp-1"
>
{round.name}
</Link>
<p className="text-xs text-muted-foreground capitalize">
{round.roundType?.toLowerCase().replace('_', ' ')}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{getStatusBadge()}
{actionsMenu}
</div>
</div>
</div>
</div>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> {/* Details row */}
<AlertDialogContent> <div className="mt-3 ml-11 grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
<AlertDialogHeader> <div>
<AlertDialogTitle>Delete Round</AlertDialogTitle> <p className="text-xs text-muted-foreground">Voting Window</p>
<AlertDialogDescription> <div className="mt-0.5">{getVotingWindow()}</div>
Are you sure you want to delete &quot;{round.name}&quot;? This will </div>
remove {round._count?.projects || 0} project assignments,{' '} <div>
{round._count?.assignments || 0} reviewer assignments, and all evaluations <p className="text-xs text-muted-foreground">Projects</p>
in this round. The projects themselves will remain in the program. This action cannot be undone. <div className="flex items-center gap-1.5 mt-0.5">
</AlertDialogDescription> <FileText className="h-3.5 w-3.5 text-muted-foreground" />
</AlertDialogHeader> <span className="font-medium">{round._count?.projects || 0}</span>
<AlertDialogFooter> </div>
<AlertDialogCancel>Cancel</AlertDialogCancel> </div>
<AlertDialogAction <div>
onClick={() => deleteRound.mutate({ id: round.id })} <p className="text-xs text-muted-foreground">Reviewers</p>
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" <div className="flex items-center gap-1.5 mt-0.5">
> <Users className="h-3.5 w-3.5 text-muted-foreground" />
{deleteRound.isPending ? ( <span className="font-medium">{round._count?.assignments || 0}</span>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> </div>
) : null} </div>
Delete </div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
{deleteDialog}
</div> </div>
) )
} }
@ -548,7 +620,8 @@ function RoundsListSkeleton() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> {/* Desktop skeleton */}
<div className="hidden lg:block space-y-3">
{[1, 2, 3].map((j) => ( {[1, 2, 3].map((j) => (
<div key={j} className="flex justify-between items-center py-2"> <div key={j} className="flex justify-between items-center py-2">
<Skeleton className="h-4 w-40" /> <Skeleton className="h-4 w-40" />
@ -560,6 +633,26 @@ function RoundsListSkeleton() {
</div> </div>
))} ))}
</div> </div>
{/* Mobile/Tablet skeleton */}
<div className="lg:hidden space-y-3">
{[1, 2, 3].map((j) => (
<div key={j} className="rounded-lg border p-4 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-7 rounded-full" />
<div className="flex-1 space-y-1">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-6 w-16" />
</div>
<div className="ml-10 grid grid-cols-2 gap-3 sm:grid-cols-3">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
</div>
))}
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}

View File

@ -4,6 +4,7 @@ import { useSearchParams } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Logo } from '@/components/shared/logo'
import { AlertCircle } from 'lucide-react' import { AlertCircle } from 'lucide-react'
const errorMessages: Record<string, string> = { const errorMessages: Record<string, string> = {
@ -21,16 +22,22 @@ export default function AuthErrorPage() {
return ( return (
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10"> <div className="mx-auto mb-4">
<Logo variant="small" />
</div>
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-6 w-6 text-destructive" /> <AlertCircle className="h-6 w-6 text-destructive" />
</div> </div>
<CardTitle className="text-xl">Authentication Error</CardTitle> <CardTitle className="text-xl">Authentication Error</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4 text-center"> <CardContent className="space-y-4 text-center">
<p className="text-muted-foreground">{message}</p> <p className="text-muted-foreground">{message}</p>
<div className="border-t pt-4"> <div className="flex gap-3 justify-center border-t pt-4">
<Button asChild> <Button asChild>
<Link href="/login">Try again</Link> <Link href="/login">Return to Login</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/">Home</Link>
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View File

@ -25,6 +25,7 @@ import {
ArrowRight, ArrowRight,
} from 'lucide-react' } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import { CountdownTimer } from '@/components/shared/countdown-timer'
async function JuryDashboardContent() { async function JuryDashboardContent() {
const session = await auth() const session = await auth()
@ -105,6 +106,27 @@ async function JuryDashboardContent() {
{} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }> {} as Record<string, { round: (typeof assignments)[0]['round']; assignments: typeof assignments }>
) )
// 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<string, Date>()
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) // Get active rounds (voting window is open)
const now = new Date() const now = new Date()
const activeRounds = Object.values(assignmentsByRound).filter( const activeRounds = Object.values(assignmentsByRound).filter(
@ -221,9 +243,15 @@ async function JuryDashboardContent() {
</div> </div>
{round.votingEndAt && ( {round.votingEndAt && (
<p className="text-xs text-muted-foreground"> <div className="flex items-center gap-2 flex-wrap">
Deadline: {formatDateOnly(round.votingEndAt)} <CountdownTimer
</p> deadline={graceByRound.get(round.id) ?? new Date(round.votingEndAt)}
label="Deadline:"
/>
<span className="text-xs text-muted-foreground">
({formatDateOnly(round.votingEndAt)})
</span>
</div>
)} )}
<Button asChild size="sm" className="w-full sm:w-auto"> <Button asChild size="sm" className="w-full sm:w-auto">

View File

@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { EvaluationForm } from '@/components/forms/evaluation-form' import { EvaluationFormWithCOI } from '@/components/forms/evaluation-form-with-coi'
import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react' import { ArrowLeft, AlertCircle, Clock, FileText, Users } from 'lucide-react'
import { isFuture, isPast } from 'date-fns' import { isFuture, isPast } from 'date-fns'
@ -21,9 +21,20 @@ interface Criterion {
id: string id: string
label: string label: string
description?: string description?: string
scale: number type?: 'numeric' | 'text' | 'boolean' | 'section_header'
scale?: number
weight?: number weight?: number
required?: boolean required?: boolean
maxLength?: number
placeholder?: string
trueLabel?: string
falseLabel?: string
condition?: {
criterionId: string
operator: 'equals' | 'greaterThan' | 'lessThan'
value: number | string | boolean
}
sectionId?: string
} }
async function EvaluateContent({ projectId }: { projectId: string }) { async function EvaluateContent({ projectId }: { projectId: string }) {
@ -133,6 +144,14 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
redirect(`/jury/projects/${projectId}/evaluation`) redirect(`/jury/projects/${projectId}/evaluation`)
} }
// Check COI status
const coiRecord = await prisma.conflictOfInterest.findUnique({
where: { assignmentId: assignment.id },
})
const coiStatus = coiRecord
? { hasConflict: coiRecord.hasConflict, declared: true }
: { hasConflict: false, declared: false }
// Get evaluation form criteria // Get evaluation form criteria
const evaluationForm = round.evaluationForms[0] const evaluationForm = round.evaluationForms[0]
if (!evaluationForm) { if (!evaluationForm) {
@ -247,8 +266,8 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
</Card> </Card>
)} )}
{/* Evaluation Form */} {/* Evaluation Form with COI Gate */}
<EvaluationForm <EvaluationFormWithCOI
assignmentId={assignment.id} assignmentId={assignment.id}
evaluationId={evaluation?.id || null} evaluationId={evaluation?.id || null}
projectTitle={project.title} projectTitle={project.title}
@ -258,7 +277,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
? { ? {
criterionScoresJson: evaluation.criterionScoresJson as Record< criterionScoresJson: evaluation.criterionScoresJson as Record<
string, string,
number number | string | boolean
> | null, > | null,
globalScore: evaluation.globalScore, globalScore: evaluation.globalScore,
binaryDecision: evaluation.binaryDecision, binaryDecision: evaluation.binaryDecision,
@ -269,6 +288,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
} }
isVotingOpen={effectiveVotingOpen} isVotingOpen={effectiveVotingOpen}
deadline={round.votingEndAt} deadline={round.votingEndAt}
coiStatus={coiStatus}
/> />
</div> </div>
) )

View File

@ -16,6 +16,7 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { FileViewer } from '@/components/shared/file-viewer' import { FileViewer } from '@/components/shared/file-viewer'
import { MentorChat } from '@/components/shared/mentor-chat'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url' import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { import {
ArrowLeft, ArrowLeft,
@ -30,6 +31,7 @@ import {
Calendar, Calendar,
FileText, FileText,
ExternalLink, ExternalLink,
MessageSquare,
} from 'lucide-react' } from 'lucide-react'
import { formatDateOnly, getInitials } from '@/lib/utils' import { formatDateOnly, getInitials } from '@/lib/utils'
@ -52,6 +54,17 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
projectId, projectId,
}) })
const { data: mentorMessages, isLoading: messagesLoading } = trpc.mentor.getMessages.useQuery({
projectId,
})
const utils = trpc.useUtils()
const sendMessage = trpc.mentor.sendMessage.useMutation({
onSuccess: () => {
utils.mentor.getMessages.invalidate({ projectId })
},
})
if (isLoading) { if (isLoading) {
return <ProjectDetailSkeleton /> return <ProjectDetailSkeleton />
} }
@ -363,6 +376,30 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Messaging Section */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Messages
</CardTitle>
<CardDescription>
Communicate with the project team
</CardDescription>
</CardHeader>
<CardContent>
<MentorChat
messages={mentorMessages || []}
currentUserId={project.mentorAssignment?.mentor?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId, message })
}}
isLoading={messagesLoading}
isSending={sendMessage.isPending}
/>
</CardContent>
</Card>
</div> </div>
) )
} }

View File

@ -67,19 +67,24 @@ async function ObserverDashboardContent() {
return ( return (
<> <>
{/* Observer Notice */} {/* Observer Notice */}
<Card className="border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30"> <div className="rounded-lg border-2 border-blue-300 bg-blue-50 px-4 py-3">
<CardContent className="flex items-center gap-3 py-4"> <div className="flex items-center gap-3">
<Eye className="h-5 w-5 text-blue-600 dark:text-blue-400" /> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100">
<Eye className="h-4 w-4 text-blue-600" />
</div>
<div> <div>
<p className="font-medium text-blue-900 dark:text-blue-100"> <div className="flex items-center gap-2">
Observer Mode <p className="font-semibold text-blue-900">Observer Mode</p>
</p> <Badge variant="outline" className="border-blue-300 text-blue-700 text-xs">
<p className="text-sm text-blue-700 dark:text-blue-300"> Read-Only
</Badge>
</div>
<p className="text-sm text-blue-700">
You have read-only access to view platform statistics and reports. You have read-only access to view platform statistics and reports.
</p> </p>
</div> </div>
</CardContent> </div>
</Card> </div>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">

View File

@ -1,7 +1,7 @@
import { Suspense } from 'react' 'use client'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic' import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { import {
Card, Card,
CardContent, CardContent,
@ -20,80 +20,72 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { FileSpreadsheet, BarChart3, Users, ClipboardList } from 'lucide-react' import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
FileSpreadsheet,
BarChart3,
Users,
ClipboardList,
CheckCircle2,
TrendingUp,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
import {
ScoreDistributionChart,
EvaluationTimelineChart,
StatusBreakdownChart,
JurorWorkloadChart,
ProjectRankingsChart,
CriteriaScoresChart,
} from '@/components/charts'
async function ReportsContent() { function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
// Get rounds with evaluation stats const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
const rounds = await prisma.round.findMany({
include: {
program: {
select: {
name: true,
},
},
_count: {
select: {
projects: true,
assignments: true,
},
},
assignments: {
select: {
id: true,
evaluation: {
select: { id: true, status: true },
},
},
},
},
orderBy: { createdAt: 'desc' },
})
// Calculate completion stats for each round const rounds = programs?.flatMap(p =>
const roundStats = rounds.map((round) => { p.rounds.map(r => ({
const totalAssignments = round._count.assignments ...r,
const completedEvaluations = round.assignments.filter( programName: `${p.year} Edition`,
(a) => a.evaluation?.status === 'SUBMITTED' }))
).length ) || []
const completionRate =
totalAssignments > 0
? Math.round((completedEvaluations / totalAssignments) * 100)
: 0
return { const { data: overviewStats, isLoading: statsLoading } =
...round, trpc.analytics.getOverviewStats.useQuery(
totalAssignments, { roundId: selectedRoundId! },
completedEvaluations, { enabled: !!selectedRoundId }
completionRate, )
}
})
// Calculate totals if (isLoading) {
const totalProjects = roundStats.reduce((acc, r) => acc + r._count.projects, 0)
const totalAssignments = roundStats.reduce(
(acc, r) => acc + r.totalAssignments,
0
)
const totalEvaluations = roundStats.reduce(
(acc, r) => acc + r.completedEvaluations,
0
)
if (rounds.length === 0) {
return ( return (
<Card> <div className="space-y-6">
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<FileSpreadsheet className="h-12 w-12 text-muted-foreground/50" /> {[...Array(4)].map((_, i) => (
<p className="mt-2 font-medium">No data to report</p> <Card key={i}>
<p className="text-sm text-muted-foreground"> <CardHeader className="space-y-0 pb-2">
Reports will appear here once rounds are created <Skeleton className="h-4 w-20" />
</p> </CardHeader>
</CardContent> <CardContent>
</Card> <Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
</div>
) )
} }
const totalProjects = rounds.reduce((acc, r) => acc + (r._count?.projects || 0), 0)
const activeRounds = rounds.filter((r) => r.status === 'ACTIVE').length
const totalPrograms = programs?.length || 0
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Quick Stats */} {/* Quick Stats */}
@ -106,7 +98,7 @@ async function ReportsContent() {
<CardContent> <CardContent>
<div className="text-2xl font-bold">{rounds.length}</div> <div className="text-2xl font-bold">{rounds.length}</div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{rounds.filter((r) => r.status === 'ACTIVE').length} active {activeRounds} active
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -124,27 +116,99 @@ async function ReportsContent() {
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle> <CardTitle className="text-sm font-medium">Active Rounds</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{totalAssignments}</div> <div className="text-2xl font-bold">{activeRounds}</div>
<p className="text-xs text-muted-foreground">Total assignments</p> <p className="text-xs text-muted-foreground">Currently active</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle> <CardTitle className="text-sm font-medium">Programs</CardTitle>
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" /> <CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{totalEvaluations}</div> <div className="text-2xl font-bold">{totalPrograms}</div>
<p className="text-xs text-muted-foreground">Completed</p> <p className="text-xs text-muted-foreground">Total programs</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Round-specific overview stats */}
{selectedRoundId && (
<>
{statsLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
) : overviewStats ? (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Selected Round Details</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.projectCount}</div>
<p className="text-xs text-muted-foreground">In this round</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.assignmentCount}</div>
<p className="text-xs text-muted-foreground">
{overviewStats.jurorCount} jurors
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.evaluationCount}</div>
<p className="text-xs text-muted-foreground">Submitted</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completion</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.completionRate}%</div>
<Progress value={overviewStats.completionRate} className="mt-2 h-2" />
</CardContent>
</Card>
</div>
</div>
) : null}
</>
)}
{/* Rounds Table - Desktop */} {/* Rounds Table - Desktop */}
<Card className="hidden md:block"> <Card className="hidden md:block">
<CardHeader> <CardHeader>
@ -158,12 +222,11 @@ async function ReportsContent() {
<TableHead>Round</TableHead> <TableHead>Round</TableHead>
<TableHead>Program</TableHead> <TableHead>Program</TableHead>
<TableHead>Projects</TableHead> <TableHead>Projects</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{roundStats.map((round) => ( {rounds.map((round) => (
<TableRow key={round.id}> <TableRow key={round.id}>
<TableCell> <TableCell>
<div> <div>
@ -175,21 +238,8 @@ async function ReportsContent() {
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell>{round.program.name}</TableCell> <TableCell>{round.programName}</TableCell>
<TableCell>{round._count.projects}</TableCell> <TableCell>{round._count?.projects || '-'}</TableCell>
<TableCell>
<div className="min-w-[120px] space-y-1">
<div className="flex justify-between text-sm">
<span>
{round.completedEvaluations}/{round.totalAssignments}
</span>
<span className="text-muted-foreground">
{round.completionRate}%
</span>
</div>
<Progress value={round.completionRate} className="h-2" />
</div>
</TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant={ variant={
@ -213,7 +263,7 @@ async function ReportsContent() {
{/* Rounds Cards - Mobile */} {/* Rounds Cards - Mobile */}
<div className="space-y-4 md:hidden"> <div className="space-y-4 md:hidden">
<h2 className="text-lg font-semibold">Round Reports</h2> <h2 className="text-lg font-semibold">Round Reports</h2>
{roundStats.map((round) => ( {rounds.map((round) => (
<Card key={round.id}> <Card key={round.id}>
<CardContent className="pt-4 space-y-3"> <CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -230,24 +280,14 @@ async function ReportsContent() {
{round.status} {round.status}
</Badge> </Badge>
</div> </div>
<p className="text-sm text-muted-foreground">{round.program.name}</p> <p className="text-sm text-muted-foreground">{round.programName}</p>
{round.votingEndAt && ( {round.votingEndAt && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Ends: {formatDateOnly(round.votingEndAt)} Ends: {formatDateOnly(round.votingEndAt)}
</p> </p>
)} )}
<div className="flex items-center justify-between text-sm"> <div className="text-sm">
<span>{round._count.projects} projects</span> <span>{round._count?.projects || 0} projects</span>
<span className="text-muted-foreground">
{round.completedEvaluations}/{round.totalAssignments} evaluations
</span>
</div>
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span>Progress</span>
<span className="text-muted-foreground">{round.completionRate}%</span>
</div>
<Progress value={round.completionRate} className="h-2" />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -257,40 +297,136 @@ async function ReportsContent() {
) )
} }
function ReportsSkeleton() { function AnalyticsTab({ selectedRoundId }: { selectedRoundId: string }) {
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const { data: timeline, isLoading: timelineLoading } =
trpc.analytics.getEvaluationTimeline.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const { data: statusBreakdown, isLoading: statusLoading } =
trpc.analytics.getStatusBreakdown.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const { data: jurorWorkload, isLoading: workloadLoading } =
trpc.analytics.getJurorWorkload.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const { data: projectRankings, isLoading: rankingsLoading } =
trpc.analytics.getProjectRankings.useQuery(
{ roundId: selectedRoundId, limit: 15 },
{ enabled: !!selectedRoundId }
)
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> {/* Row 1: Score Distribution & Status Breakdown */}
{[...Array(4)].map((_, i) => ( <div className="grid gap-6 lg:grid-cols-2">
<Card key={i}> {scoreLoading ? (
<CardHeader className="space-y-0 pb-2"> <Skeleton className="h-[350px]" />
<Skeleton className="h-4 w-20" /> ) : scoreDistribution ? (
</CardHeader> <ScoreDistributionChart
<CardContent> data={scoreDistribution.distribution}
<Skeleton className="h-8 w-16" /> averageScore={scoreDistribution.averageScore}
<Skeleton className="mt-2 h-3 w-24" /> totalScores={scoreDistribution.totalScores}
</CardContent> />
</Card> ) : null}
))}
{statusLoading ? (
<Skeleton className="h-[350px]" />
) : statusBreakdown ? (
<StatusBreakdownChart data={statusBreakdown} />
) : null}
</div> </div>
<Card>
<CardHeader> {/* Row 2: Evaluation Timeline */}
<Skeleton className="h-6 w-32" /> {timelineLoading ? (
<Skeleton className="h-4 w-48" /> <Skeleton className="h-[350px]" />
</CardHeader> ) : timeline?.length ? (
<CardContent> <EvaluationTimelineChart data={timeline} />
<div className="space-y-4"> ) : (
{[...Array(3)].map((_, i) => ( <Card>
<Skeleton key={i} className="h-16 w-full" /> <CardContent className="flex items-center justify-center py-12">
))} <p className="text-muted-foreground">
</div> No evaluation data available yet
</CardContent> </p>
</Card> </CardContent>
</Card>
)}
{/* Row 3: Criteria Scores */}
{criteriaLoading ? (
<Skeleton className="h-[350px]" />
) : criteriaScores?.length ? (
<CriteriaScoresChart data={criteriaScores} />
) : null}
{/* Row 4: Juror Workload */}
{workloadLoading ? (
<Skeleton className="h-[450px]" />
) : jurorWorkload?.length ? (
<JurorWorkloadChart data={jurorWorkload} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No juror assignments yet
</p>
</CardContent>
</Card>
)}
{/* Row 5: Project Rankings */}
{rankingsLoading ? (
<Skeleton className="h-[550px]" />
) : projectRankings?.length ? (
<ProjectRankingsChart data={projectRankings} limit={15} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No project scores available yet
</p>
</CardContent>
</Card>
)}
</div> </div>
) )
} }
export default function ObserverReportsPage() { export default function ObserverReportsPage() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({
...r,
programName: `${p.year} Edition`,
}))
) || []
// Set default selected round
if (rounds.length && !selectedRoundId) {
setSelectedRoundId(rounds[0].id)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@ -301,10 +437,62 @@ export default function ObserverReportsPage() {
</p> </p>
</div> </div>
{/* Content */} {/* Round Selector */}
<Suspense fallback={<ReportsSkeleton />}> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<ReportsContent /> <label className="text-sm font-medium">Select Round:</label>
</Suspense> {roundsLoading ? (
<Skeleton className="h-10 w-full sm:w-[300px]" />
) : rounds.length > 0 ? (
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">No rounds available</p>
)}
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="space-y-6">
<TabsList>
<TabsTrigger value="overview" className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics" className="gap-2" disabled={!selectedRoundId}>
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<OverviewTab selectedRoundId={selectedRoundId} />
</TabsContent>
<TabsContent value="analytics">
{selectedRoundId ? (
<AnalyticsTab selectedRoundId={selectedRoundId} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round from the dropdown above to view analytics
</p>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div> </div>
) )
} }

View File

@ -1,6 +1,4 @@
import { Inter } from 'next/font/google' import { Logo } from '@/components/shared/logo'
const inter = Inter({ subsets: ['latin'] })
export default function PublicLayout({ export default function PublicLayout({
children, children,
@ -8,14 +6,12 @@ export default function PublicLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
return ( return (
<div className={`min-h-screen bg-background ${inter.className}`}> <div className="min-h-screen bg-background font-sans">
{/* Simple header */} {/* Simple header */}
<header className="border-b bg-card"> <header className="border-b bg-card">
<div className="container mx-auto px-4 py-4"> <div className="container mx-auto px-4 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center"> <Logo variant="small" />
<span className="text-sm font-bold text-white">M</span>
</div>
<span className="font-semibold">Monaco Ocean Protection Challenge</span> <span className="font-semibold">Monaco Ocean Protection Challenge</span>
</div> </div>
</div> </div>

View File

@ -1,5 +1,6 @@
'use client' 'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
@ -16,17 +17,21 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { StatusTracker } from '@/components/shared/status-tracker' import { StatusTracker } from '@/components/shared/status-tracker'
import { MentorChat } from '@/components/shared/mentor-chat'
import { import {
ArrowLeft, ArrowLeft,
FileText, FileText,
Clock, Clock,
AlertCircle, AlertCircle,
AlertTriangle,
Download, Download,
Video, Video,
File, File,
Users, Users,
Crown, Crown,
MessageSquare,
} from 'lucide-react' } from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = { const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
@ -64,12 +69,25 @@ export function SubmissionDetailClient() {
const params = useParams() const params = useParams()
const { data: session } = useSession() const { data: session } = useSession()
const projectId = params.id as string const projectId = params.id as string
const [activeTab, setActiveTab] = useState('details')
const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery( const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
{ projectId }, { projectId },
{ enabled: !!session?.user } { enabled: !!session?.user }
) )
const { data: mentorMessages, isLoading: messagesLoading } = trpc.applicant.getMentorMessages.useQuery(
{ projectId },
{ enabled: !!session?.user && activeTab === 'mentor' }
)
const utils = trpc.useUtils()
const sendMessage = trpc.applicant.sendMentorMessage.useMutation({
onSuccess: () => {
utils.applicant.getMentorMessages.invalidate({ projectId })
},
})
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
@ -148,43 +166,161 @@ export function SubmissionDetailClient() {
</Alert> </Alert>
)} )}
<div className="grid gap-6 lg:grid-cols-3"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
{/* Main content */} <TabsList>
<div className="lg:col-span-2 space-y-6"> <TabsTrigger value="details">Details</TabsTrigger>
{/* Project details */} <TabsTrigger value="documents">Documents</TabsTrigger>
<Card> <TabsTrigger value="mentor" className="gap-1.5">
<CardHeader> <MessageSquare className="h-3.5 w-3.5" />
<CardTitle>Project Details</CardTitle> Mentor
</CardHeader> </TabsTrigger>
<CardContent className="space-y-4"> </TabsList>
{project.teamName && (
<div>
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
<p>{project.teamName}</p>
</div>
)}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground">Description</p>
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Files */} {/* Details Tab */}
<TabsContent value="details">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Project details */}
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{project.teamName && (
<div>
<p className="text-sm font-medium text-muted-foreground">Team/Organization</p>
<p>{project.teamName}</p>
</div>
)}
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground">Description</p>
<p className="whitespace-pre-wrap">{project.description}</p>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Metadata */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
<Card>
<CardHeader>
<CardTitle>Additional Information</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
<div key={key} className="flex justify-between">
<dt className="text-sm font-medium text-muted-foreground capitalize">
{key.replace(/_/g, ' ')}
</dt>
<dd className="text-sm">{String(value)}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status timeline */}
<Card>
<CardHeader>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
<StatusTracker
timeline={timeline}
currentStatus={currentStatus}
/>
</CardContent>
</Card>
{/* Dates */}
<Card>
<CardHeader>
<CardTitle>Key Dates</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
</div>
{project.submittedAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
</CardContent>
</Card>
{/* Team Members */}
{'teamMembers' in project && project.teamMembers && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/my-submission/${projectId}/team` as Route}>
Manage
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => (
<div key={member.id} className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-4 w-4 text-yellow-500" />
) : (
<span className="text-xs font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{member.user.name || member.user.email}
</p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
</TabsContent>
{/* Documents Tab */}
<TabsContent value="documents">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Uploaded Documents</CardTitle> <CardTitle>Uploaded Documents</CardTitle>
@ -201,6 +337,7 @@ export function SubmissionDetailClient() {
<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 }
return ( return (
<div <div
@ -210,7 +347,15 @@ export function SubmissionDetailClient() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" /> <Icon className="h-5 w-5 text-muted-foreground" />
<div> <div>
<p className="font-medium">{file.fileName}</p> <div className="flex items-center gap-2">
<p className="font-medium">{file.fileName}</p>
{fileRecord.isLate && (
<Badge variant="warning" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" />
Submitted late
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{fileTypeLabels[file.fileType] || file.fileType} {fileTypeLabels[file.fileType] || file.fileType}
</p> </p>
@ -226,110 +371,34 @@ export function SubmissionDetailClient() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
{/* Metadata */} {/* Mentor Tab */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && ( <TabsContent value="mentor">
<Card>
<CardHeader>
<CardTitle>Additional Information</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-3">
{Object.entries(project.metadataJson as Record<string, unknown>).map(([key, value]) => (
<div key={key} className="flex justify-between">
<dt className="text-sm font-medium text-muted-foreground capitalize">
{key.replace(/_/g, ' ')}
</dt>
<dd className="text-sm">{String(value)}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status timeline */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Status Timeline</CardTitle> <CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Mentor Communication
</CardTitle>
<CardDescription>
Chat with your assigned mentor
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<StatusTracker <MentorChat
timeline={timeline} messages={mentorMessages || []}
currentStatus={currentStatus} currentUserId={session?.user?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId, message })
}}
isLoading={messagesLoading}
isSending={sendMessage.isPending}
/> />
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
{/* Dates */} </Tabs>
<Card>
<CardHeader>
<CardTitle>Key Dates</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
</div>
{project.submittedAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span>{new Date(project.submittedAt).toLocaleDateString()}</span>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
</CardContent>
</Card>
{/* Team Members */}
{'teamMembers' in project && project.teamMembers && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Team
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={`/my-submission/${projectId}/team` as Route}>
Manage
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{(project.teamMembers as Array<{ id: string; role: string; user: { name: string | null; email: string } }>).map((member) => (
<div key={member.id} className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-4 w-4 text-yellow-500" />
) : (
<span className="text-xs font-medium">
{member.user.name?.charAt(0).toUpperCase() || '?'}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{member.user.name || member.user.email}
</p>
<p className="text-xs text-muted-foreground">
{member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
</div> </div>
) )
} }

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
@ -73,15 +73,17 @@ export default function ProfileSettingsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
// Populate form when user data loads // Populate form when user data loads
if (user && !profileLoaded) { useEffect(() => {
setName(user.name || '') if (user && !profileLoaded) {
const meta = (user.metadataJson as Record<string, unknown>) || {} setName(user.name || '')
setBio((meta.bio as string) || '') const meta = (user.metadataJson as Record<string, unknown>) || {}
setPhoneNumber(user.phoneNumber || '') setBio((meta.bio as string) || '')
setNotificationPreference(user.notificationPreference || 'EMAIL') setPhoneNumber(user.phoneNumber || '')
setExpertiseTags(user.expertiseTags || []) setNotificationPreference(user.notificationPreference || 'EMAIL')
setProfileLoaded(true) setExpertiseTags(user.expertiseTags || [])
} setProfileLoaded(true)
}
}, [user, profileLoaded])
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
try { try {
@ -237,19 +239,6 @@ export default function ProfileSettingsPage() {
/> />
</div> </div>
<div className="flex justify-end">
<Button
onClick={handleSaveProfile}
disabled={updateProfile.isPending}
>
{updateProfile.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
@ -283,19 +272,6 @@ export default function ProfileSettingsPage() {
</Select> </Select>
</div> </div>
<div className="flex justify-end">
<Button
onClick={handleSaveProfile}
disabled={updateProfile.isPending}
>
{updateProfile.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Preferences
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
@ -318,22 +294,25 @@ export default function ProfileSettingsPage() {
maxTags={15} maxTags={15}
/> />
<div className="flex justify-end">
<Button
onClick={handleSaveProfile}
disabled={updateProfile.isPending}
>
{updateProfile.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Expertise
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Save All Profile Changes */}
<div className="flex justify-end">
<Button
onClick={handleSaveProfile}
disabled={updateProfile.isPending}
size="lg"
>
{updateProfile.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save All Changes
</Button>
</div>
{/* Change Password */} {/* Change Password */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@ -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<NextResponse> {
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 }
)
}
}

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { checkRateLimit } from '@/lib/rate-limit' import { checkRateLimit } from '@/lib/rate-limit'
import { auth } from '@/lib/auth'
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com' const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
const SMTP_HOST = process.env.SMTP_HOST || 'localhost' const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
@ -23,6 +24,15 @@ function validateNewPassword(password: string): string | null {
} }
export async function POST(request: NextRequest): Promise<NextResponse> { export async function POST(request: NextRequest): Promise<NextResponse> {
// Verify authenticated session
const session = await auth()
if (!session?.user?.email) {
return NextResponse.json(
{ error: 'Authentication required.' },
{ status: 401 }
)
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown' const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
const rateLimit = checkRateLimit(`email-change:${ip}`, 3, 15 * 60 * 1000) const rateLimit = checkRateLimit(`email-change:${ip}`, 3, 15 * 60 * 1000)
@ -50,6 +60,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
const emailLower = email.toLowerCase().trim() const emailLower = email.toLowerCase().trim()
// Verify the user can only change their own email password
if (emailLower !== session.user.email.toLowerCase()) {
return NextResponse.json(
{ error: 'You can only change your own email password.' },
{ status: 403 }
)
}
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) { if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
return NextResponse.json( return NextResponse.json(
{ error: `Email must be an @${MAIL_DOMAIN} address.` }, { error: `Email must be an @${MAIL_DOMAIN} address.` },

View File

@ -1,12 +1,22 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer' import nodemailer from 'nodemailer'
import { checkRateLimit } from '@/lib/rate-limit' import { checkRateLimit } from '@/lib/rate-limit'
import { auth } from '@/lib/auth'
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com' const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
const SMTP_HOST = process.env.SMTP_HOST || 'localhost' const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587') const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
export async function POST(request: NextRequest): Promise<NextResponse> { export async function POST(request: NextRequest): Promise<NextResponse> {
// Verify authenticated session
const session = await auth()
if (!session?.user?.email) {
return NextResponse.json(
{ error: 'Authentication required.' },
{ status: 401 }
)
}
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown' const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
const rateLimit = checkRateLimit(`email-verify:${ip}`, 5, 15 * 60 * 1000) const rateLimit = checkRateLimit(`email-verify:${ip}`, 5, 15 * 60 * 1000)
@ -30,6 +40,14 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
const emailLower = email.toLowerCase().trim() const emailLower = email.toLowerCase().trim()
// Verify the user can only check their own email credentials
if (emailLower !== session.user.email.toLowerCase()) {
return NextResponse.json(
{ error: 'You can only verify your own email credentials.' },
{ status: 403 }
)
}
if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) { if (!emailLower.endsWith(`@${MAIL_DOMAIN}`)) {
return NextResponse.json( return NextResponse.json(
{ error: `Email must be an @${MAIL_DOMAIN} address.` }, { error: `Email must be an @${MAIL_DOMAIN} address.` },

View File

@ -26,7 +26,6 @@ export async function GET() {
services: { services: {
database: 'disconnected', database: 'disconnected',
}, },
error: error instanceof Error ? error.message : 'Unknown error',
}, },
{ status: 503 } { status: 503 }
) )

View File

@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import Link from 'next/link'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
@ -12,25 +13,32 @@ export default function Error({
reset: () => void reset: () => void
}) { }) {
useEffect(() => { useEffect(() => {
// Log the error to an error reporting service console.error('Application error:', error)
console.error(error)
}, [error]) }, [error])
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center"> <div className="flex min-h-screen flex-col items-center justify-center px-4 py-16 text-center">
<AlertTriangle className="h-16 w-16 text-destructive/50" /> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<h1 className="text-2xl font-semibold">Something went wrong</h1> <AlertTriangle className="h-8 w-8 text-primary" />
<p className="max-w-md text-muted-foreground"> </div>
An unexpected error occurred. Please try again or contact support if the <h1 className="mt-6 text-display font-bold text-brand-blue">
problem persists. Something went wrong
</h1>
<p className="mt-4 max-w-md text-body text-muted-foreground">
An unexpected error occurred. Please try again or return to the
dashboard.
</p> </p>
{error.digest && ( {error.digest && (
<p className="text-xs text-muted-foreground">Error ID: {error.digest}</p> <p className="mt-2 text-tiny text-muted-foreground/60">
Error ID: {error.digest}
</p>
)} )}
<div className="flex gap-4"> <div className="mt-8 flex gap-4">
<Button onClick={() => reset()}>Try Again</Button> <Button size="lg" onClick={() => reset()}>
<Button variant="outline" onClick={() => window.location.reload()}> Try Again
Refresh Page </Button>
<Button variant="outline" size="lg" asChild>
<Link href="/">Return to Dashboard</Link>
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -159,7 +159,7 @@
--secondary-foreground: 198 85% 18%; --secondary-foreground: 198 85% 18%;
--muted: 30 6% 96%; --muted: 30 6% 96%;
--muted-foreground: 30 8% 45%; --muted-foreground: 30 8% 38%;
/* Accent - MOPC Teal */ /* Accent - MOPC Teal */
--accent: 194 25% 44%; --accent: 194 25% 44%;

View File

@ -20,7 +20,7 @@ export default function RootLayout({
children: React.ReactNode children: React.ReactNode
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en" className="light">
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers> <Providers>{children}</Providers>
<Toaster <Toaster

View File

@ -1,18 +1,53 @@
import Link from 'next/link' import Link from 'next/link'
import Image from 'next/image'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { FileQuestion } from 'lucide-react'
export default function NotFound() { export default function NotFound() {
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center"> <div className="flex min-h-screen flex-col bg-background">
<FileQuestion className="h-16 w-16 text-muted-foreground/50" /> {/* Header */}
<h1 className="text-2xl font-semibold">Page Not Found</h1> <header className="border-b border-border bg-white">
<p className="text-muted-foreground"> <div className="container-app flex h-16 items-center">
The page you&apos;re looking for doesn&apos;t exist or has been moved. <Link href="/">
</p> <Image
<Button asChild> src="/images/MOPC-blue-long.png"
<Link href="/">Go Home</Link> alt="MOPC - Monaco Ocean Protection Challenge"
</Button> width={140}
height={45}
className="h-10 w-auto"
priority
/>
</Link>
</div>
</header>
{/* Content */}
<main className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
<p className="text-[8rem] font-bold leading-none tracking-tight text-brand-blue/10 sm:text-[12rem]">
404
</p>
<h1 className="-mt-4 text-display font-bold text-brand-blue sm:-mt-8">
Page Not Found
</h1>
<p className="mt-4 max-w-md text-body text-muted-foreground">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<div className="mt-8">
<Button asChild size="lg">
<Link href="/">Return to Dashboard</Link>
</Button>
</div>
</main>
{/* Footer */}
<footer className="border-t border-border bg-brand-blue py-6 text-white">
<div className="container-app text-center">
<p className="text-small">
&copy; {new Date().getFullYear()} Monaco Ocean Protection Challenge.
All rights reserved.
</p>
</div>
</footer>
</div> </div>
) )
} }

View File

@ -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<string, number>
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<string, { badge: 'default' | 'secondary' | 'destructive'; bg: string }> = {
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 (
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
)
}
// No summary exists yet
if (!summary) {
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5" />
AI Evaluation Summary
</CardTitle>
<CardDescription>
Generate an AI-powered analysis of jury evaluations
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-6 text-center">
<Sparkles className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground mb-4">
No summary generated yet. Click below to analyze submitted evaluations.
</p>
<Button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{isGenerating ? 'Generating...' : 'Generate Summary'}
</Button>
</div>
</CardContent>
</Card>
)
}
const summaryData = summary.summaryJson as unknown as SummaryJson
const patterns = summaryData.scoringPatterns
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5" />
AI Evaluation Summary
</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">
<Clock className="h-3 w-3" />
Generated {formatDistanceToNow(new Date(summary.generatedAt), { addSuffix: true })}
{' '}using {summary.model}
</CardDescription>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={isGenerating}>
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Regenerate
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate Summary</AlertDialogTitle>
<AlertDialogDescription>
This will replace the existing AI summary with a new one.
This uses your OpenAI API quota.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleGenerate}>
Regenerate
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Scoring Stats */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<Target className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">
{patterns.averageGlobalScore !== null
? patterns.averageGlobalScore.toFixed(1)
: '-'}
</p>
<p className="text-xs text-muted-foreground">Avg Score</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">
{Math.round(patterns.consensus * 100)}%
</p>
<p className="text-xs text-muted-foreground">Consensus</p>
</div>
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted">
<Users className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-2xl font-bold">{patterns.evaluatorCount}</p>
<p className="text-xs text-muted-foreground">Evaluators</p>
</div>
</div>
</div>
{/* Overall Assessment */}
<div>
<p className="text-sm font-medium mb-2">Overall Assessment</p>
<p className="text-sm text-muted-foreground leading-relaxed">
{summaryData.overallAssessment}
</p>
</div>
{/* Strengths & Weaknesses */}
<div className="grid gap-4 sm:grid-cols-2">
{summaryData.strengths.length > 0 && (
<div>
<p className="text-sm font-medium mb-2 text-green-700">Strengths</p>
<ul className="space-y-1">
{summaryData.strengths.map((s, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-green-500 flex-shrink-0" />
{s}
</li>
))}
</ul>
</div>
)}
{summaryData.weaknesses.length > 0 && (
<div>
<p className="text-sm font-medium mb-2 text-amber-700">Weaknesses</p>
<ul className="space-y-1">
{summaryData.weaknesses.map((w, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-amber-500 flex-shrink-0" />
{w}
</li>
))}
</ul>
</div>
)}
</div>
{/* Themes */}
{summaryData.themes.length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Key Themes</p>
<div className="space-y-2">
{summaryData.themes.map((theme, i) => (
<div
key={i}
className="flex items-center justify-between p-2 rounded-lg border"
>
<div className="flex items-center gap-2">
<Badge
className={sentimentColors[theme.sentiment]?.bg}
variant="outline"
>
{theme.sentiment}
</Badge>
<span className="text-sm">{theme.theme}</span>
</div>
<span className="text-xs text-muted-foreground">
{theme.frequency} mention{theme.frequency !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
</div>
)}
{/* Criterion Averages */}
{Object.keys(patterns.criterionAverages).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Criterion Averages</p>
<div className="space-y-2">
{Object.entries(patterns.criterionAverages).map(([label, avg]) => (
<div key={label} className="flex items-center gap-3">
<span className="text-sm text-muted-foreground flex-1 min-w-0 truncate">
{label}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="w-24 h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${(avg / 10) * 100}%` }}
/>
</div>
<span className="text-sm font-medium w-8 text-right">
{avg.toFixed(1)}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Recommendation */}
{summaryData.recommendation && (
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200">
<p className="text-sm font-medium text-blue-900 mb-1">
Recommendation
</p>
<p className="text-sm text-blue-700">
{summaryData.recommendation}
</p>
</div>
)}
</CardContent>
</Card>
)
}

View File

@ -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<boolean | null>(null)
const [conflictType, setConflictType] = useState<string>('')
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 (
<AlertDialog open={open}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<ShieldAlert className="h-5 w-5 text-amber-500" />
Conflict of Interest Declaration
</AlertDialogTitle>
<AlertDialogDescription>
Before evaluating &ldquo;{projectTitle}&rdquo;, please declare whether
you have any conflict of interest with this project.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-3">
<Label className="text-sm font-medium">
Do you have a conflict of interest with this project?
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={hasConflict === false ? 'default' : 'outline'}
className="flex-1"
onClick={() => setHasConflict(false)}
>
No Conflict
</Button>
<Button
type="button"
variant={hasConflict === true ? 'destructive' : 'outline'}
className="flex-1"
onClick={() => setHasConflict(true)}
>
Yes, I Have a Conflict
</Button>
</div>
</div>
{hasConflict && (
<>
<div className="space-y-2">
<Label htmlFor="conflict-type">Type of Conflict</Label>
<Select value={conflictType} onValueChange={setConflictType}>
<SelectTrigger id="conflict-type">
<SelectValue placeholder="Select conflict type..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="financial">Financial Interest</SelectItem>
<SelectItem value="personal">Personal Relationship</SelectItem>
<SelectItem value="organizational">Organizational Affiliation</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="conflict-description">
Description <span className="text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="conflict-description"
placeholder="Briefly describe the nature of your conflict..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
maxLength={1000}
/>
</div>
</>
)}
</div>
<AlertDialogFooter>
<Button
onClick={handleSubmit}
disabled={!canSubmit}
>
{declareCOI.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{hasConflict === null
? 'Select an option'
: hasConflict
? 'Submit Declaration'
: 'Confirm No Conflict'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@ -45,16 +45,42 @@ import {
GripVertical, GripVertical,
Check, Check,
X, X,
Type,
ToggleLeft,
Hash,
Heading,
ThumbsUp,
ThumbsDown,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
export interface CriterionCondition {
criterionId: string
operator: 'equals' | 'greaterThan' | 'lessThan'
value: number | string | boolean
}
export interface Criterion { export interface Criterion {
id: string id: string
label: string label: string
description?: string description?: string
scale: number // 5 or 10 type?: CriterionType // defaults to 'numeric'
// Numeric fields
scale?: number // 5 or 10
weight?: number weight?: number
required: boolean required?: boolean
// Text fields
maxLength?: number
placeholder?: string
// Boolean fields
trueLabel?: string
falseLabel?: string
// Conditional visibility
condition?: CriterionCondition
// Section grouping
sectionId?: string
} }
interface EvaluationFormBuilderProps { interface EvaluationFormBuilderProps {
@ -67,17 +93,34 @@ function generateId(): string {
return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
} }
function createDefaultCriterion(): Criterion { function createDefaultCriterion(type: CriterionType = 'numeric'): Criterion {
return { const base: Criterion = {
id: generateId(), id: generateId(),
label: '', label: '',
description: '', description: '',
scale: 5, type,
weight: 1, }
required: true, switch (type) {
case 'numeric':
return { ...base, scale: 5, weight: 1, required: true }
case 'text':
return { ...base, maxLength: 1000, placeholder: '', required: true }
case 'boolean':
return { ...base, trueLabel: 'Yes', falseLabel: 'No', required: true }
case 'section_header':
return { ...base, required: false }
default:
return { ...base, scale: 5, weight: 1, required: true }
} }
} }
const CRITERION_TYPE_OPTIONS: { value: CriterionType; label: string; icon: typeof Hash }[] = [
{ value: 'numeric', label: 'Numeric Score', icon: Hash },
{ value: 'text', label: 'Text Response', icon: Type },
{ value: 'boolean', label: 'Yes / No', icon: ToggleLeft },
{ value: 'section_header', label: 'Section Header', icon: Heading },
]
export function EvaluationFormBuilder({ export function EvaluationFormBuilder({
initialCriteria = [], initialCriteria = [],
onChange, onChange,
@ -97,8 +140,8 @@ export function EvaluationFormBuilder({
) )
// Add new criterion // Add new criterion
const addCriterion = useCallback(() => { const addCriterion = useCallback((type: CriterionType = 'numeric') => {
const newCriterion = createDefaultCriterion() const newCriterion = createDefaultCriterion(type)
const newCriteria = [...criteria, newCriterion] const newCriteria = [...criteria, newCriterion]
updateCriteria(newCriteria) updateCriteria(newCriteria)
setEditingId(newCriterion.id) setEditingId(newCriterion.id)
@ -190,13 +233,24 @@ export function EvaluationFormBuilder({
{isEditing && editDraft ? ( {isEditing && editDraft ? (
// Edit mode // Edit mode
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
{/* Type indicator */}
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{CRITERION_TYPE_OPTIONS.find((t) => t.value === (editDraft.type || 'numeric'))?.label ?? 'Numeric Score'}
</Badge>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`label-${criterion.id}`}>Label *</Label> <Label htmlFor={`label-${criterion.id}`}>Label *</Label>
<Input <Input
id={`label-${criterion.id}`} id={`label-${criterion.id}`}
value={editDraft.label} value={editDraft.label}
onChange={(e) => updateDraft({ label: e.target.value })} onChange={(e) => updateDraft({ label: e.target.value })}
placeholder="e.g., Innovation" placeholder={
(editDraft.type || 'numeric') === 'section_header'
? 'e.g., Technical Assessment'
: 'e.g., Innovation'
}
disabled={disabled} disabled={disabled}
autoFocus autoFocus
/> />
@ -217,64 +271,246 @@ export function EvaluationFormBuilder({
/> />
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> {/* Type-specific fields */}
<div className="space-y-2"> {(editDraft.type || 'numeric') === 'numeric' && (
<Label htmlFor={`scale-${criterion.id}`}>Scale</Label> <div className="grid gap-4 sm:grid-cols-3">
<Select <div className="space-y-2">
value={String(editDraft.scale)} <Label htmlFor={`scale-${criterion.id}`}>Scale</Label>
onValueChange={(v) => updateDraft({ scale: parseInt(v) })} <Select
disabled={disabled} value={String(editDraft.scale ?? 5)}
> onValueChange={(v) => updateDraft({ scale: parseInt(v) })}
<SelectTrigger id={`scale-${criterion.id}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">1-5</SelectItem>
<SelectItem value="10">1-10</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor={`weight-${criterion.id}`}>
Weight: {editDraft.weight ?? 1}x
</Label>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-4">0.5</span>
<Slider
id={`weight-${criterion.id}`}
min={0.5}
max={3}
step={0.5}
value={[editDraft.weight ?? 1]}
onValueChange={(v) => updateDraft({ weight: v[0] })}
disabled={disabled} disabled={disabled}
className="flex-1" >
/> <SelectTrigger id={`scale-${criterion.id}`}>
<span className="text-xs text-muted-foreground w-4">3</span> <SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">1-5</SelectItem>
<SelectItem value="10">1-10</SelectItem>
</SelectContent>
</Select>
</div> </div>
<p className="text-xs text-muted-foreground">
{(editDraft.weight ?? 1) === 1
? 'Normal importance'
: (editDraft.weight ?? 1) < 1
? 'Lower importance'
: 'Higher importance'}
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Required</Label> <Label htmlFor={`weight-${criterion.id}`}>
<div className="flex items-center h-10"> Weight: {editDraft.weight ?? 1}x
<Switch </Label>
checked={editDraft.required} <div className="flex items-center gap-3">
onCheckedChange={(checked) => <span className="text-xs text-muted-foreground w-4">0.5</span>
updateDraft({ required: checked }) <Slider
} id={`weight-${criterion.id}`}
min={0.5}
max={3}
step={0.5}
value={[editDraft.weight ?? 1]}
onValueChange={(v) => updateDraft({ weight: v[0] })}
disabled={disabled}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-4">3</span>
</div>
<p className="text-xs text-muted-foreground">
{(editDraft.weight ?? 1) === 1
? 'Normal importance'
: (editDraft.weight ?? 1) < 1
? 'Lower importance'
: 'Higher importance'}
</p>
</div>
<div className="space-y-2">
<Label>Required</Label>
<div className="flex items-center h-10">
<Switch
checked={editDraft.required ?? true}
onCheckedChange={(checked) =>
updateDraft({ required: checked })
}
disabled={disabled}
/>
</div>
</div>
</div>
)}
{(editDraft.type || 'numeric') === 'text' && (
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`maxLength-${criterion.id}`}>Max Length</Label>
<Input
id={`maxLength-${criterion.id}`}
type="number"
min={1}
max={10000}
value={editDraft.maxLength ?? 1000}
onChange={(e) => updateDraft({ maxLength: parseInt(e.target.value) || 1000 })}
disabled={disabled} disabled={disabled}
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor={`placeholder-${criterion.id}`}>Placeholder</Label>
<Input
id={`placeholder-${criterion.id}`}
value={editDraft.placeholder || ''}
onChange={(e) => updateDraft({ placeholder: e.target.value })}
placeholder="Enter placeholder text..."
disabled={disabled}
/>
</div>
<div className="space-y-2">
<Label>Required</Label>
<div className="flex items-center h-10">
<Switch
checked={editDraft.required ?? true}
onCheckedChange={(checked) =>
updateDraft({ required: checked })
}
disabled={disabled}
/>
</div>
</div>
</div> </div>
</div> )}
{(editDraft.type || 'numeric') === 'boolean' && (
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`trueLabel-${criterion.id}`}>Yes Label</Label>
<Input
id={`trueLabel-${criterion.id}`}
value={editDraft.trueLabel || 'Yes'}
onChange={(e) => updateDraft({ trueLabel: e.target.value })}
placeholder="Yes"
disabled={disabled}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`falseLabel-${criterion.id}`}>No Label</Label>
<Input
id={`falseLabel-${criterion.id}`}
value={editDraft.falseLabel || 'No'}
onChange={(e) => updateDraft({ falseLabel: e.target.value })}
placeholder="No"
disabled={disabled}
/>
</div>
<div className="space-y-2">
<Label>Required</Label>
<div className="flex items-center h-10">
<Switch
checked={editDraft.required ?? true}
onCheckedChange={(checked) =>
updateDraft({ required: checked })
}
disabled={disabled}
/>
</div>
</div>
</div>
)}
{/* Condition builder - available for all types except section_header */}
{(editDraft.type || 'numeric') !== 'section_header' && criteria.filter((c) => c.id !== editDraft.id).length > 0 && (
<div className="space-y-2 border-t pt-4">
<div className="flex items-center justify-between">
<Label>Conditional Visibility</Label>
{editDraft.condition ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => updateDraft({ condition: undefined })}
disabled={disabled}
>
<X className="mr-1 h-3 w-3" />
Remove
</Button>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
updateDraft({
condition: {
criterionId: criteria.filter((c) => c.id !== editDraft.id)[0]?.id ?? '',
operator: 'equals',
value: 0,
},
})
}
disabled={disabled}
>
Add Condition
</Button>
)}
</div>
{editDraft.condition && (
<div className="grid gap-2 sm:grid-cols-3 p-3 rounded-md bg-muted/50">
<div className="space-y-1">
<Label className="text-xs">When criterion</Label>
<Select
value={editDraft.condition.criterionId}
onValueChange={(v) =>
updateDraft({ condition: { ...editDraft.condition!, criterionId: v } })
}
disabled={disabled}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{criteria
.filter((c) => c.id !== editDraft.id && (c.type || 'numeric') !== 'section_header')
.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.label || '(Untitled)'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Operator</Label>
<Select
value={editDraft.condition.operator}
onValueChange={(v) =>
updateDraft({ condition: { ...editDraft.condition!, operator: v as 'equals' | 'greaterThan' | 'lessThan' } })
}
disabled={disabled}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="equals">equals</SelectItem>
<SelectItem value="greaterThan">greater than</SelectItem>
<SelectItem value="lessThan">less than</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Value</Label>
<Input
value={String(editDraft.condition.value)}
onChange={(e) => {
const raw = e.target.value
const parsed = Number(raw)
updateDraft({
condition: {
...editDraft.condition!,
value: isNaN(parsed) ? (raw === 'true' ? true : raw === 'false' ? false : raw) : parsed,
},
})
}}
disabled={disabled}
placeholder="Value"
/>
</div>
</div>
)}
</div>
)}
{/* Edit actions */} {/* Edit actions */}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
@ -310,22 +546,37 @@ export function EvaluationFormBuilder({
{/* Criterion info */} {/* Criterion info */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-medium truncate"> <span className={cn(
'font-medium truncate',
(criterion.type || 'numeric') === 'section_header' && 'text-base font-semibold'
)}>
{criterion.label || '(Untitled)'} {criterion.label || '(Untitled)'}
</span> </span>
<Badge variant="secondary" className="shrink-0 text-xs"> {(() => {
1-{criterion.scale} const type = criterion.type || 'numeric'
</Badge> const TypeIcon = CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.icon ?? Hash
{criterion.weight && criterion.weight !== 1 && ( return (
<Badge variant="secondary" className="shrink-0 text-xs gap-1">
<TypeIcon className="h-3 w-3" />
{type === 'numeric' ? `1-${criterion.scale ?? 5}` : CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.label}
</Badge>
)
})()}
{criterion.weight && criterion.weight !== 1 && (criterion.type || 'numeric') === 'numeric' && (
<Badge variant="outline" className="shrink-0 text-xs"> <Badge variant="outline" className="shrink-0 text-xs">
{criterion.weight}x {criterion.weight}x
</Badge> </Badge>
)} )}
{criterion.required && ( {criterion.required && (criterion.type || 'numeric') !== 'section_header' && (
<Badge variant="default" className="shrink-0 text-xs"> <Badge variant="default" className="shrink-0 text-xs">
Required Required
</Badge> </Badge>
)} )}
{criterion.condition && (
<Badge variant="outline" className="shrink-0 text-xs text-amber-600 border-amber-300">
Conditional
</Badge>
)}
</div> </div>
{criterion.description && ( {criterion.description && (
<p className="text-sm text-muted-foreground truncate mt-0.5"> <p className="text-sm text-muted-foreground truncate mt-0.5">
@ -418,17 +669,20 @@ export function EvaluationFormBuilder({
{/* Actions */} {/* Actions */}
{!disabled && ( {!disabled && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 flex-wrap">
<Button {CRITERION_TYPE_OPTIONS.map(({ value, label, icon: Icon }) => (
type="button" <Button
variant="outline" key={value}
size="sm" type="button"
onClick={addCriterion} variant="outline"
disabled={editingId !== null} size="sm"
> onClick={() => addCriterion(value)}
<Plus className="mr-1 h-4 w-4" /> disabled={editingId !== null}
Add Criterion >
</Button> <Icon className="mr-1 h-4 w-4" />
{label}
</Button>
))}
{criteria.length > 0 && ( {criteria.length > 0 && (
<PreviewDialog criteria={criteria} /> <PreviewDialog criteria={criteria} />
@ -458,57 +712,94 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
</DialogHeader> </DialogHeader>
<div className="space-y-6 py-4"> <div className="space-y-6 py-4">
{criteria.map((criterion) => ( {criteria.map((criterion) => {
<Card key={criterion.id}> const type = criterion.type || 'numeric'
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
{criterion.label}
{criterion.required && (
<Badge variant="destructive" className="text-xs">
Required
</Badge>
)}
</CardTitle>
{criterion.description && (
<CardDescription>{criterion.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">1</span>
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary/30 rounded-full"
style={{ width: '50%' }}
/>
</div>
<span className="text-xs text-muted-foreground w-4">
{criterion.scale}
</span>
</div>
<div className="flex gap-1 flex-wrap"> if (type === 'section_header') {
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map( return (
(num) => ( <div key={criterion.id} className="border-b pb-2 pt-4">
<div <h3 className="font-semibold text-lg">{criterion.label}</h3>
key={num} {criterion.description && (
className={cn( <p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
'w-9 h-9 rounded-md text-sm font-medium flex items-center justify-center', )}
num <= Math.ceil(criterion.scale / 2)
? 'bg-primary/20 text-primary'
: 'bg-muted'
)}
>
{num}
</div>
)
)}
</div>
</div> </div>
</CardContent> )
</Card> }
))}
return (
<Card key={criterion.id}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
{criterion.label}
{criterion.required && (
<Badge variant="destructive" className="text-xs">
Required
</Badge>
)}
</CardTitle>
{criterion.description && (
<CardDescription>{criterion.description}</CardDescription>
)}
</CardHeader>
<CardContent>
{type === 'numeric' && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">1</span>
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary/30 rounded-full"
style={{ width: '50%' }}
/>
</div>
<span className="text-xs text-muted-foreground w-4">
{criterion.scale ?? 5}
</span>
</div>
<div className="flex gap-1 flex-wrap">
{Array.from({ length: criterion.scale ?? 5 }, (_, i) => i + 1).map(
(num) => (
<div
key={num}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium flex items-center justify-center',
num <= Math.ceil((criterion.scale ?? 5) / 2)
? 'bg-primary/20 text-primary'
: 'bg-muted'
)}
>
{num}
</div>
)
)}
</div>
</div>
)}
{type === 'text' && (
<Textarea
placeholder={criterion.placeholder || 'Enter your response...'}
rows={3}
maxLength={criterion.maxLength ?? 1000}
disabled
className="opacity-60"
/>
)}
{type === 'boolean' && (
<div className="flex gap-4">
<div className="flex-1 h-12 rounded-md border flex items-center justify-center text-sm font-medium bg-muted/50">
<ThumbsUp className="mr-2 h-4 w-4" />
{criterion.trueLabel || 'Yes'}
</div>
<div className="flex-1 h-12 rounded-md border flex items-center justify-center text-sm font-medium bg-muted/50">
<ThumbsDown className="mr-2 h-4 w-4" />
{criterion.falseLabel || 'No'}
</div>
</div>
)}
</CardContent>
</Card>
)
})}
{criteria.length === 0 && ( {criteria.length === 0 && (
<p className="text-center text-muted-foreground py-8"> <p className="text-center text-muted-foreground py-8">

View File

@ -0,0 +1,112 @@
'use client'
import { useState } from 'react'
import { EvaluationForm } from './evaluation-form'
import { COIDeclarationDialog } from './coi-declaration-dialog'
import { Card, CardContent } from '@/components/ui/card'
import { ShieldAlert } from 'lucide-react'
interface Criterion {
id: string
label: string
description?: string
type?: 'numeric' | 'text' | 'boolean' | 'section_header'
scale?: number
weight?: number
required?: boolean
maxLength?: number
placeholder?: string
trueLabel?: string
falseLabel?: string
condition?: {
criterionId: string
operator: 'equals' | 'greaterThan' | 'lessThan'
value: number | string | boolean
}
sectionId?: string
}
interface EvaluationFormWithCOIProps {
assignmentId: string
evaluationId: string | null
projectTitle: string
criteria: Criterion[]
initialData?: {
criterionScoresJson: Record<string, number | string | boolean> | null
globalScore: number | null
binaryDecision: boolean | null
feedbackText: string | null
status: string
}
isVotingOpen: boolean
deadline?: Date | null
coiStatus: {
hasConflict: boolean
declared: boolean
}
}
export function EvaluationFormWithCOI({
assignmentId,
evaluationId,
projectTitle,
criteria,
initialData,
isVotingOpen,
deadline,
coiStatus,
}: EvaluationFormWithCOIProps) {
const [coiDeclared, setCOIDeclared] = useState(coiStatus.declared)
const [hasConflict, setHasConflict] = useState(coiStatus.hasConflict)
const handleCOIComplete = (conflictDeclared: boolean) => {
setCOIDeclared(true)
setHasConflict(conflictDeclared)
}
// Show COI dialog if not yet declared
if (!coiDeclared) {
return (
<COIDeclarationDialog
open={true}
assignmentId={assignmentId}
projectTitle={projectTitle}
onComplete={handleCOIComplete}
/>
)
}
// Show warning banner if conflict was declared
if (hasConflict) {
return (
<Card className="border-amber-500 bg-amber-500/5">
<CardContent className="flex items-center gap-3 py-6">
<ShieldAlert className="h-6 w-6 text-amber-600 shrink-0" />
<div>
<p className="font-medium text-amber-800 dark:text-amber-200">
Conflict of Interest Declared
</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
You declared a conflict of interest for this project. An admin will
review your declaration. You cannot evaluate this project while the
conflict is under review.
</p>
</div>
</CardContent>
</Card>
)
}
// No conflict - show the evaluation form
return (
<EvaluationForm
assignmentId={assignmentId}
evaluationId={evaluationId}
projectTitle={projectTitle}
criteria={criteria}
initialData={initialData}
isVotingOpen={isVotingOpen}
deadline={deadline}
/>
)
}

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useCallback, useTransition } from 'react' import { useState, useEffect, useCallback, useTransition, useMemo } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useForm, Controller } from 'react-hook-form' import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
@ -14,6 +14,7 @@ import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -38,15 +39,36 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' import { toast } from 'sonner'
import { CountdownTimer } from '@/components/shared/countdown-timer'
// Define criterion type from the evaluation form JSON // Define criterion type from the evaluation form JSON
type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
interface CriterionCondition {
criterionId: string
operator: 'equals' | 'greaterThan' | 'lessThan'
value: number | string | boolean
}
interface Criterion { interface Criterion {
id: string id: string
label: string label: string
description?: string description?: string
scale: number // max value (e.g., 5 or 10) type?: CriterionType // defaults to 'numeric'
// Numeric
scale?: number // max value (e.g., 5 or 10)
weight?: number weight?: number
required?: boolean required?: boolean
// Text
maxLength?: number
placeholder?: string
// Boolean
trueLabel?: string
falseLabel?: string
// Conditional visibility
condition?: CriterionCondition
// Section grouping
sectionId?: string
} }
interface EvaluationFormProps { interface EvaluationFormProps {
@ -55,7 +77,7 @@ interface EvaluationFormProps {
projectTitle: string projectTitle: string
criteria: Criterion[] criteria: Criterion[]
initialData?: { initialData?: {
criterionScoresJson: Record<string, number> | null criterionScoresJson: Record<string, number | string | boolean> | null
globalScore: number | null globalScore: number | null
binaryDecision: boolean | null binaryDecision: boolean | null
feedbackText: string | null feedbackText: string | null
@ -65,15 +87,52 @@ interface EvaluationFormProps {
deadline?: Date | null deadline?: Date | null
} }
const createEvaluationSchema = (criteria: Criterion[]) => const createEvaluationSchema = (criteria: Criterion[]) => {
z.object({ const criterionFields: Record<string, z.ZodTypeAny> = {}
criterionScores: z.record(z.number()), for (const c of criteria) {
const type = c.type || 'numeric'
if (type === 'section_header') continue
if (type === 'numeric') {
criterionFields[c.id] = z.number()
} else if (type === 'text') {
criterionFields[c.id] = z.string()
} else if (type === 'boolean') {
criterionFields[c.id] = z.boolean()
}
}
return z.object({
criterionScores: z.object(criterionFields).passthrough(),
globalScore: z.number().int().min(1).max(10), globalScore: z.number().int().min(1).max(10),
binaryDecision: z.boolean(), binaryDecision: z.boolean(),
feedbackText: z.string().min(10, 'Please provide at least 10 characters of feedback'), feedbackText: z.string().min(10, 'Please provide at least 10 characters of feedback'),
}) })
}
type EvaluationFormData = z.infer<ReturnType<typeof createEvaluationSchema>> type EvaluationFormData = {
criterionScores: Record<string, number | string | boolean>
globalScore: number
binaryDecision: boolean
feedbackText: string
}
/** Evaluate whether a condition is met based on current form values */
function evaluateCondition(
condition: CriterionCondition,
scores: Record<string, number | string | boolean>
): boolean {
const val = scores[condition.criterionId]
if (val === undefined) return false
switch (condition.operator) {
case 'equals':
return val === condition.value
case 'greaterThan':
return typeof val === 'number' && typeof condition.value === 'number' && val > condition.value
case 'lessThan':
return typeof val === 'number' && typeof condition.value === 'number' && val < condition.value
default:
return true
}
}
export function EvaluationForm({ export function EvaluationForm({
assignmentId, assignmentId,
@ -89,10 +148,41 @@ export function EvaluationForm({
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [lastSaved, setLastSaved] = useState<Date | null>(null) const [lastSaved, setLastSaved] = useState<Date | null>(null)
// Progress tracking state
const [touchedCriteria, setTouchedCriteria] = useState<Set<string>>(() => {
const initial = new Set<string>()
if (initialData?.criterionScoresJson) {
for (const key of Object.keys(initialData.criterionScoresJson)) {
initial.add(key)
}
}
return initial
})
const [globalScoreTouched, setGlobalScoreTouched] = useState(
() => initialData?.globalScore != null
)
const [decisionTouched, setDecisionTouched] = useState(
() => initialData?.binaryDecision != null
)
// Compute which criteria are scorable (not section headers)
const scorableCriteria = useMemo(
() => criteria.filter((c) => (c.type || 'numeric') !== 'section_header'),
[criteria]
)
// Initialize criterion scores with existing data or defaults // Initialize criterion scores with existing data or defaults
const defaultCriterionScores: Record<string, number> = {} const defaultCriterionScores: Record<string, number | string | boolean> = {}
criteria.forEach((c) => { scorableCriteria.forEach((c) => {
defaultCriterionScores[c.id] = initialData?.criterionScoresJson?.[c.id] ?? Math.ceil(c.scale / 2) const type = c.type || 'numeric'
const existing = initialData?.criterionScoresJson?.[c.id]
if (type === 'numeric') {
defaultCriterionScores[c.id] = typeof existing === 'number' ? existing : Math.ceil((c.scale ?? 5) / 2)
} else if (type === 'text') {
defaultCriterionScores[c.id] = typeof existing === 'string' ? existing : ''
} else if (type === 'boolean') {
defaultCriterionScores[c.id] = typeof existing === 'boolean' ? existing : false
}
}) })
const form = useForm<EvaluationFormData>({ const form = useForm<EvaluationFormData>({
@ -109,6 +199,46 @@ export function EvaluationForm({
const { watch, handleSubmit, control, formState } = form const { watch, handleSubmit, control, formState } = form
const { errors, isValid, isDirty } = formState const { errors, isValid, isDirty } = formState
// Progress tracking callbacks
const onCriterionTouch = useCallback((criterionId: string) => {
setTouchedCriteria((prev) => {
if (prev.has(criterionId)) return prev
const next = new Set(prev)
next.add(criterionId)
return next
})
}, [])
// Compute progress - section_headers count as always complete (skip them)
const feedbackValue = watch('feedbackText')
const watchedScores = watch('criterionScores')
const progress = useMemo(() => {
// Only count scorable criteria (not section headers)
const totalItems = scorableCriteria.length + 3
// Count completed criteria
let criteriaDone = 0
for (const c of scorableCriteria) {
const type = c.type || 'numeric'
if (type === 'numeric') {
if (touchedCriteria.has(c.id)) criteriaDone++
} else if (type === 'text') {
const val = watchedScores?.[c.id]
if (typeof val === 'string' && val.length > 0) criteriaDone++
} else if (type === 'boolean') {
if (touchedCriteria.has(c.id)) criteriaDone++
}
}
const completedItems =
criteriaDone +
(globalScoreTouched ? 1 : 0) +
(decisionTouched ? 1 : 0) +
(feedbackValue.length >= 10 ? 1 : 0)
const percentage = Math.round((completedItems / totalItems) * 100)
return { totalItems, completedItems, percentage, criteriaDone, criteriaTotal: scorableCriteria.length }
}, [scorableCriteria, touchedCriteria, watchedScores, globalScoreTouched, decisionTouched, feedbackValue])
// tRPC mutations // tRPC mutations
const startEvaluation = trpc.evaluation.start.useMutation() const startEvaluation = trpc.evaluation.start.useMutation()
const autosave = trpc.evaluation.autosave.useMutation() const autosave = trpc.evaluation.autosave.useMutation()
@ -206,10 +336,20 @@ export function EvaluationForm({
{/* Status bar */} {/* Status bar */}
<div className="sticky top-0 z-10 -mx-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-3 border-b"> <div className="sticky top-0 z-10 -mx-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 py-3 border-b">
<div className="flex items-center justify-between flex-wrap gap-2"> <div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 min-w-0 flex-1">
<h2 className="font-semibold truncate max-w-[200px] sm:max-w-none"> <h2 className="font-semibold truncate max-w-[200px] sm:max-w-none">
{projectTitle} {projectTitle}
</h2> </h2>
{!isReadOnly && deadline && (
<CountdownTimer deadline={new Date(deadline)} />
)}
{!isReadOnly && (
<ProgressIndicator
percentage={progress.percentage}
criteriaDone={progress.criteriaDone}
criteriaTotal={progress.criteriaTotal}
/>
)}
<AutosaveIndicator status={autosaveStatus} lastSaved={lastSaved} /> <AutosaveIndicator status={autosaveStatus} lastSaved={lastSaved} />
</div> </div>
@ -273,14 +413,61 @@ export function EvaluationForm({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{criteria.map((criterion) => ( {criteria.map((criterion) => {
<CriterionField const type = criterion.type || 'numeric'
key={criterion.id}
criterion={criterion} // Evaluate conditional visibility
control={control} if (criterion.condition) {
disabled={isReadOnly} const conditionMet = evaluateCondition(criterion.condition, watchedScores ?? {})
/> if (!conditionMet) return null
))} }
// Section header
if (type === 'section_header') {
return <SectionHeaderField key={criterion.id} criterion={criterion} />
}
// Numeric (default)
if (type === 'numeric') {
return (
<NumericCriterionField
key={criterion.id}
criterion={criterion}
control={control}
disabled={isReadOnly}
onTouch={onCriterionTouch}
/>
)
}
// Text
if (type === 'text') {
return (
<TextCriterionField
key={criterion.id}
criterion={criterion}
control={control}
disabled={isReadOnly}
onTouch={onCriterionTouch}
/>
)
}
// Boolean
if (type === 'boolean') {
return (
<BooleanCriterionField
key={criterion.id}
criterion={criterion}
control={control}
disabled={isReadOnly}
onTouch={onCriterionTouch}
/>
)
}
return null
})}
</CardContent> </CardContent>
</Card> </Card>
)} )}
@ -309,7 +496,10 @@ export function EvaluationForm({
max={10} max={10}
step={1} step={1}
value={[field.value]} value={[field.value]}
onValueChange={(v) => field.onChange(v[0])} onValueChange={(v) => {
field.onChange(v[0])
setGlobalScoreTouched(true)
}}
disabled={isReadOnly} disabled={isReadOnly}
className="py-4" className="py-4"
/> />
@ -318,7 +508,12 @@ export function EvaluationForm({
<button <button
key={num} key={num}
type="button" type="button"
onClick={() => !isReadOnly && field.onChange(num)} onClick={() => {
if (!isReadOnly) {
field.onChange(num)
setGlobalScoreTouched(true)
}
}}
disabled={isReadOnly} disabled={isReadOnly}
className={cn( className={cn(
'w-8 h-8 rounded-full text-sm font-medium transition-colors', 'w-8 h-8 rounded-full text-sm font-medium transition-colors',
@ -359,7 +554,12 @@ export function EvaluationForm({
'flex-1 h-20', 'flex-1 h-20',
field.value && 'bg-green-600 hover:bg-green-700' field.value && 'bg-green-600 hover:bg-green-700'
)} )}
onClick={() => !isReadOnly && field.onChange(true)} onClick={() => {
if (!isReadOnly) {
field.onChange(true)
setDecisionTouched(true)
}
}}
disabled={isReadOnly} disabled={isReadOnly}
> >
<ThumbsUp className="mr-2 h-6 w-6" /> <ThumbsUp className="mr-2 h-6 w-6" />
@ -372,7 +572,12 @@ export function EvaluationForm({
'flex-1 h-20', 'flex-1 h-20',
!field.value && 'bg-red-600 hover:bg-red-700' !field.value && 'bg-red-600 hover:bg-red-700'
)} )}
onClick={() => !isReadOnly && field.onChange(false)} onClick={() => {
if (!isReadOnly) {
field.onChange(false)
setDecisionTouched(true)
}
}}
disabled={isReadOnly} disabled={isReadOnly}
> >
<ThumbsDown className="mr-2 h-6 w-6" /> <ThumbsDown className="mr-2 h-6 w-6" />
@ -484,16 +689,31 @@ export function EvaluationForm({
) )
} }
// Criterion field component // Section header component (no input)
function CriterionField({ function SectionHeaderField({ criterion }: { criterion: Criterion }) {
return (
<div className="border-b pb-2 pt-4 first:pt-0">
<h3 className="font-semibold text-lg">{criterion.label}</h3>
{criterion.description && (
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
)}
</div>
)
}
// Numeric criterion field component (original behavior)
function NumericCriterionField({
criterion, criterion,
control, control,
disabled, disabled,
onTouch,
}: { }: {
criterion: Criterion criterion: Criterion
control: any control: any
disabled: boolean disabled: boolean
onTouch: (criterionId: string) => void
}) { }) {
const scale = criterion.scale ?? 5
return ( return (
<Controller <Controller
name={`criterionScores.${criterion.id}`} name={`criterionScores.${criterion.id}`}
@ -510,7 +730,7 @@ function CriterionField({
)} )}
</div> </div>
<Badge variant="secondary" className="shrink-0"> <Badge variant="secondary" className="shrink-0">
{field.value}/{criterion.scale} {field.value}/{scale}
</Badge> </Badge>
</div> </div>
@ -518,23 +738,31 @@ function CriterionField({
<span className="text-xs text-muted-foreground w-4">1</span> <span className="text-xs text-muted-foreground w-4">1</span>
<Slider <Slider
min={1} min={1}
max={criterion.scale} max={scale}
step={1} step={1}
value={[field.value]} value={[typeof field.value === 'number' ? field.value : Math.ceil(scale / 2)]}
onValueChange={(v) => field.onChange(v[0])} onValueChange={(v) => {
field.onChange(v[0])
onTouch(criterion.id)
}}
disabled={disabled} disabled={disabled}
className="flex-1" className="flex-1"
/> />
<span className="text-xs text-muted-foreground w-4">{criterion.scale}</span> <span className="text-xs text-muted-foreground w-4">{scale}</span>
</div> </div>
{/* Visual rating buttons */} {/* Visual rating buttons */}
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map((num) => ( {Array.from({ length: scale }, (_, i) => i + 1).map((num) => (
<button <button
key={num} key={num}
type="button" type="button"
onClick={() => !disabled && field.onChange(num)} onClick={() => {
if (!disabled) {
field.onChange(num)
onTouch(criterion.id)
}
}}
disabled={disabled} disabled={disabled}
className={cn( className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors', 'w-9 h-9 rounded-md text-sm font-medium transition-colors',
@ -556,6 +784,159 @@ function CriterionField({
) )
} }
// Text criterion field component
function TextCriterionField({
criterion,
control,
disabled,
onTouch,
}: {
criterion: Criterion
control: any
disabled: boolean
onTouch: (criterionId: string) => void
}) {
return (
<Controller
name={`criterionScores.${criterion.id}`}
control={control}
render={({ field }) => (
<div className="space-y-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-base font-medium">{criterion.label}</Label>
{criterion.required && (
<Badge variant="destructive" className="text-xs">Required</Badge>
)}
</div>
{criterion.description && (
<p className="text-sm text-muted-foreground">
{criterion.description}
</p>
)}
</div>
<Textarea
value={typeof field.value === 'string' ? field.value : ''}
onChange={(e) => {
field.onChange(e.target.value)
onTouch(criterion.id)
}}
placeholder={criterion.placeholder || 'Enter your response...'}
rows={3}
maxLength={criterion.maxLength ?? 1000}
disabled={disabled}
/>
<p className="text-xs text-muted-foreground">
{typeof field.value === 'string' ? field.value.length : 0}
{criterion.maxLength ? ` / ${criterion.maxLength}` : ''} characters
</p>
</div>
)}
/>
)
}
// Boolean criterion field component
function BooleanCriterionField({
criterion,
control,
disabled,
onTouch,
}: {
criterion: Criterion
control: any
disabled: boolean
onTouch: (criterionId: string) => void
}) {
const trueLabel = criterion.trueLabel || 'Yes'
const falseLabel = criterion.falseLabel || 'No'
return (
<Controller
name={`criterionScores.${criterion.id}`}
control={control}
render={({ field }) => (
<div className="space-y-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Label className="text-base font-medium">{criterion.label}</Label>
{criterion.required && (
<Badge variant="destructive" className="text-xs">Required</Badge>
)}
</div>
{criterion.description && (
<p className="text-sm text-muted-foreground">
{criterion.description}
</p>
)}
</div>
<div className="flex gap-3">
<Button
type="button"
variant={field.value === true ? 'default' : 'outline'}
className={cn(
'flex-1 h-12',
field.value === true && 'bg-green-600 hover:bg-green-700'
)}
onClick={() => {
if (!disabled) {
field.onChange(true)
onTouch(criterion.id)
}
}}
disabled={disabled}
>
<ThumbsUp className="mr-2 h-4 w-4" />
{trueLabel}
</Button>
<Button
type="button"
variant={field.value === false ? 'default' : 'outline'}
className={cn(
'flex-1 h-12',
field.value === false && 'bg-red-600 hover:bg-red-700'
)}
onClick={() => {
if (!disabled) {
field.onChange(false)
onTouch(criterion.id)
}
}}
disabled={disabled}
>
<ThumbsDown className="mr-2 h-4 w-4" />
{falseLabel}
</Button>
</div>
</div>
)}
/>
)
}
// Progress indicator component
function ProgressIndicator({
percentage,
criteriaDone,
criteriaTotal,
}: {
percentage: number
criteriaDone: number
criteriaTotal: number
}) {
return (
<div className="flex items-center gap-2 shrink-0">
<Progress value={percentage} className="w-16 sm:w-24 h-2" />
<span className="text-xs text-muted-foreground whitespace-nowrap">
<span className="sm:hidden">{percentage}%</span>
<span className="hidden sm:inline">
{criteriaDone} of {criteriaTotal} criteria scored &middot; {percentage}%
</span>
</span>
</div>
)
}
// Autosave indicator component // Autosave indicator component
function AutosaveIndicator({ function AutosaveIndicator({
status, status,

View File

@ -244,26 +244,26 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
<div className="border-t p-3"> <div className="border-t p-3">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-slate-100 dark:hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"> <button className="group flex w-full items-center gap-3 rounded-xl p-2.5 text-left transition-all duration-200 hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{/* Avatar */} {/* Avatar */}
<div className="relative shrink-0"> <div className="relative shrink-0">
<UserAvatar user={user} avatarUrl={avatarUrl} size="md" /> <UserAvatar user={user} avatarUrl={avatarUrl} size="md" />
{/* Online indicator */} {/* Online indicator */}
<div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-white bg-emerald-500" /> <div className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-background bg-emerald-500" />
</div> </div>
{/* User info */} {/* User info */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-100"> <p className="truncate text-sm font-semibold text-foreground">
{user.name || 'User'} {user.name || 'User'}
</p> </p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400"> <p className="truncate text-xs text-muted-foreground">
{roleLabel} {roleLabel}
</p> </p>
</div> </div>
{/* Chevron */} {/* Chevron */}
<ChevronRight className="h-4 w-4 shrink-0 text-slate-400 transition-transform duration-200 group-hover:translate-x-0.5 group-hover:text-slate-600" /> <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200 group-hover:translate-x-0.5 group-hover:text-foreground" />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@ -1,183 +1,37 @@
'use client' 'use client'
import { useState } from 'react' import { BookOpen, ClipboardList, Home } from 'lucide-react'
import Link from 'next/link' import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar'
import { trpc } from '@/lib/trpc/client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type { Route } from 'next'
import { BookOpen, ClipboardList, Home, LogOut, Menu, Settings, User, X } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
interface JuryNavProps { const navigation: NavItem[] = [
user: {
name?: string | null
email?: string | null
}
}
const navigation = [
{ {
name: 'Dashboard', name: 'Dashboard',
href: '/jury' as const, href: '/jury',
icon: Home, icon: Home,
}, },
{ {
name: 'My Assignments', name: 'My Assignments',
href: '/jury/assignments' as const, href: '/jury/assignments',
icon: ClipboardList, icon: ClipboardList,
}, },
{ {
name: 'Learning Hub', name: 'Learning Hub',
href: '/jury/learning' as const, href: '/jury/learning',
icon: BookOpen, icon: BookOpen,
}, },
] ]
interface JuryNavProps {
user: RoleNavUser
}
export function JuryNav({ user }: JuryNavProps) { export function JuryNav({ user }: JuryNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
return ( return (
<> <RoleNav
{/* Desktop header */} navigation={navigation}
<header className="sticky top-0 z-40 border-b bg-card"> roleName="Jury"
<div className="container-app"> user={user}
<div className="flex h-16 items-center justify-between"> basePath="/jury"
{/* Logo */} />
<Logo showText textSuffix="Jury" />
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/jury' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="gap-2 hidden sm:flex"
size="sm"
>
<UserAvatar user={user} avatarUrl={avatarUrl} size="xs" />
<span className="max-w-[120px] truncate">
{user.name || user.email}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled>
<User className="mr-2 h-4 w-4" />
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={"/settings/profile" as Route} className="flex cursor-pointer items-center">
<Settings className="mr-2 h-4 w-4" />
Profile Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
</div>
{/* Mobile menu */}
{isMobileMenuOpen && (
<div className="border-t md:hidden">
<nav className="container-app py-4 space-y-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/jury' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
<div className="border-t pt-4 mt-4">
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</Button>
</div>
</nav>
</div>
)}
</header>
</>
) )
} }

View File

@ -1,183 +1,37 @@
'use client' 'use client'
import { useState } from 'react' import { BookOpen, Home, Users } from 'lucide-react'
import Link from 'next/link' import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar'
import { trpc } from '@/lib/trpc/client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { BookOpen, Home, LogOut, Menu, Settings, User, Users, X } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
interface MentorNavProps { const navigation: NavItem[] = [
user: {
name?: string | null
email?: string | null
}
}
const navigation: { name: string; href: Route; icon: typeof Home }[] = [
{ {
name: 'Dashboard', name: 'Dashboard',
href: '/mentor' as Route, href: '/mentor',
icon: Home, icon: Home,
}, },
{ {
name: 'My Mentees', name: 'My Mentees',
href: '/mentor/projects' as Route, href: '/mentor/projects',
icon: Users, icon: Users,
}, },
{ {
name: 'Resources', name: 'Resources',
href: '/mentor/resources' as Route, href: '/mentor/resources',
icon: BookOpen, icon: BookOpen,
}, },
] ]
interface MentorNavProps {
user: RoleNavUser
}
export function MentorNav({ user }: MentorNavProps) { export function MentorNav({ user }: MentorNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
return ( return (
<> <RoleNav
{/* Desktop header */} navigation={navigation}
<header className="sticky top-0 z-40 border-b bg-card"> roleName="Mentor"
<div className="container-app"> user={user}
<div className="flex h-16 items-center justify-between"> basePath="/mentor"
{/* Logo */} />
<Logo showText textSuffix="Mentor" />
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/mentor' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="gap-2 hidden sm:flex"
size="sm"
>
<UserAvatar user={user} avatarUrl={avatarUrl} size="xs" />
<span className="max-w-[120px] truncate">
{user.name || user.email}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled>
<User className="mr-2 h-4 w-4" />
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={"/settings/profile" as Route} className="flex cursor-pointer items-center">
<Settings className="mr-2 h-4 w-4" />
Profile Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
</div>
{/* Mobile menu */}
{isMobileMenuOpen && (
<div className="border-t md:hidden">
<nav className="container-app py-4 space-y-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/mentor' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
<div className="border-t pt-4 mt-4">
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</Button>
</div>
</nav>
</div>
)}
</header>
</>
) )
} }

View File

@ -1,169 +1,32 @@
'use client' 'use client'
import { useState } from 'react' import { BarChart3, Home } from 'lucide-react'
import Link from 'next/link' import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar'
import { trpc } from '@/lib/trpc/client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type { Route } from 'next'
import { Home, BarChart3, Menu, X, LogOut, Eye, Settings } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
interface ObserverNavProps { const navigation: NavItem[] = [
user: {
name?: string | null
email?: string | null
}
}
const navigation = [
{ {
name: 'Dashboard', name: 'Dashboard',
href: '/observer' as const, href: '/observer',
icon: Home, icon: Home,
}, },
{ {
name: 'Reports', name: 'Reports',
href: '/observer/reports' as const, href: '/observer/reports',
icon: BarChart3, icon: BarChart3,
}, },
] ]
interface ObserverNavProps {
user: RoleNavUser
}
export function ObserverNav({ user }: ObserverNavProps) { export function ObserverNav({ user }: ObserverNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
return ( return (
<header className="sticky top-0 z-40 border-b bg-card"> <RoleNav
<div className="container-app flex h-16 items-center justify-between"> navigation={navigation}
{/* Logo */} roleName="Observer"
<Logo showText textSuffix="Observer" /> user={user}
basePath="/observer"
{/* Desktop Navigation */} />
<nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/observer' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
{/* User Menu */}
<div className="flex items-center gap-2">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-2">
<UserAvatar user={user} avatarUrl={avatarUrl} size="xs" />
<span className="hidden sm:inline text-sm truncate max-w-[120px]">
{user.name || user.email}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem disabled className="text-xs text-muted-foreground">
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={"/settings/profile" as Route} className="flex cursor-pointer items-center">
<Settings className="mr-2 h-4 w-4" />
Profile Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Mobile menu button */}
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
{/* Mobile Navigation */}
{isMobileMenuOpen && (
<div className="border-t md:hidden">
<nav className="container-app py-3 space-y-1">
{navigation.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== '/observer' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
<div className="pt-2 border-t">
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</Button>
</div>
</nav>
</div>
)}
</header>
) )
} }

View File

@ -0,0 +1,175 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar'
import { trpc } from '@/lib/trpc/client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import type { Route } from 'next'
import type { LucideIcon } from 'lucide-react'
import { LogOut, Menu, Settings, User, X } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
export type NavItem = {
name: string
href: string
icon: LucideIcon
}
export type RoleNavUser = {
name?: string | null
email?: string | null
}
type RoleNavProps = {
navigation: NavItem[]
roleName: string
user: RoleNavUser
/** The base path for the role (e.g., '/jury', '/mentor', '/observer'). Used for active state detection on the dashboard link. */
basePath: string
}
function isNavItemActive(pathname: string, href: string, basePath: string): boolean {
return pathname === href || (href !== basePath && pathname.startsWith(href))
}
export function RoleNav({ navigation, roleName, user, basePath }: RoleNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
return (
<header className="sticky top-0 z-40 border-b bg-card">
<div className="container-app">
<div className="flex h-16 items-center justify-between">
{/* Logo */}
<Logo showText textSuffix={roleName} />
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1">
{navigation.map((item) => {
const isActive = isNavItemActive(pathname, item.href, basePath)
return (
<Link
key={item.name}
href={item.href as Route}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
</nav>
{/* User menu & mobile toggle */}
<div className="flex items-center gap-2">
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="gap-2 hidden sm:flex"
size="sm"
>
<UserAvatar user={user} avatarUrl={avatarUrl} size="xs" />
<span className="max-w-[120px] truncate">
{user.name || user.email}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem disabled>
<User className="mr-2 h-4 w-4" />
{user.email}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={"/settings/profile" as Route} className="flex cursor-pointer items-center">
<Settings className="mr-2 h-4 w-4" />
Profile Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => signOut({ callbackUrl: '/login' })}
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="md:hidden"
aria-label={isMobileMenuOpen ? 'Close menu' : 'Open menu'}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
>
{isMobileMenuOpen ? (
<X className="h-5 w-5" />
) : (
<Menu className="h-5 w-5" />
)}
</Button>
</div>
</div>
</div>
{/* Mobile menu */}
{isMobileMenuOpen && (
<div className="border-t md:hidden">
<nav className="container-app py-4 space-y-1">
{navigation.map((item) => {
const isActive = isNavItemActive(pathname, item.href, basePath)
return (
<Link
key={item.name}
href={item.href as Route}
onClick={() => setIsMobileMenuOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
)
})}
<div className="border-t pt-4 mt-4">
<Button
variant="ghost"
className="w-full justify-start text-destructive hover:text-destructive"
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</Button>
</div>
</nav>
</div>
)}
</header>
)
}

View File

@ -0,0 +1,105 @@
'use client'
import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Clock, AlertTriangle } from 'lucide-react'
interface CountdownTimerProps {
deadline: Date
label?: string
className?: string
}
interface TimeRemaining {
days: number
hours: number
minutes: number
seconds: number
totalMs: number
}
function getTimeRemaining(deadline: Date): TimeRemaining {
const totalMs = deadline.getTime() - Date.now()
if (totalMs <= 0) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0 }
}
const seconds = Math.floor((totalMs / 1000) % 60)
const minutes = Math.floor((totalMs / 1000 / 60) % 60)
const hours = Math.floor((totalMs / (1000 * 60 * 60)) % 24)
const days = Math.floor(totalMs / (1000 * 60 * 60 * 24))
return { days, hours, minutes, seconds, totalMs }
}
function formatCountdown(time: TimeRemaining): string {
if (time.totalMs <= 0) return 'Deadline passed'
const { days, hours, minutes, seconds } = time
// Less than 1 hour: show minutes and seconds
if (days === 0 && hours === 0) {
return `${minutes}m ${seconds}s`
}
// Less than 24 hours: show hours and minutes
if (days === 0) {
return `${hours}h ${minutes}m ${seconds}s`
}
// More than 24 hours: show days, hours, minutes
return `${days}d ${hours}h ${minutes}m`
}
type Urgency = 'expired' | 'critical' | 'warning' | 'normal'
function getUrgency(totalMs: number): Urgency {
if (totalMs <= 0) return 'expired'
if (totalMs < 60 * 60 * 1000) return 'critical' // < 1 hour
if (totalMs < 24 * 60 * 60 * 1000) return 'warning' // < 24 hours
return 'normal'
}
const urgencyStyles: Record<Urgency, string> = {
expired: 'text-muted-foreground bg-muted',
critical: 'text-red-700 bg-red-50 border-red-200 dark:text-red-400 dark:bg-red-950/50 dark:border-red-900',
warning: 'text-amber-700 bg-amber-50 border-amber-200 dark:text-amber-400 dark:bg-amber-950/50 dark:border-amber-900',
normal: 'text-green-700 bg-green-50 border-green-200 dark:text-green-400 dark:bg-green-950/50 dark:border-green-900',
}
export function CountdownTimer({ deadline, label, className }: CountdownTimerProps) {
const [time, setTime] = useState<TimeRemaining>(() => getTimeRemaining(deadline))
useEffect(() => {
const timer = setInterval(() => {
const remaining = getTimeRemaining(deadline)
setTime(remaining)
if (remaining.totalMs <= 0) {
clearInterval(timer)
}
}, 1000)
return () => clearInterval(timer)
}, [deadline])
const urgency = getUrgency(time.totalMs)
const displayText = formatCountdown(time)
return (
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium',
urgencyStyles[urgency],
className
)}
>
{urgency === 'critical' ? (
<AlertTriangle className="h-3 w-3 shrink-0" />
) : (
<Clock className="h-3 w-3 shrink-0" />
)}
{label && <span className="hidden sm:inline">{label}</span>}
<span className="tabular-nums">{displayText}</span>
</div>
)
}

View File

@ -0,0 +1,177 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Skeleton } from '@/components/ui/skeleton'
import { Send, MessageSquare } from 'lucide-react'
interface Message {
id: string
message: string
createdAt: Date | string
isRead: boolean
sender: {
id: string
name: string | null
email: string
role?: string
}
}
interface MentorChatProps {
messages: Message[]
currentUserId: string
onSendMessage: (message: string) => Promise<void>
isLoading?: boolean
isSending?: boolean
className?: string
}
export function MentorChat({
messages,
currentUserId,
onSendMessage,
isLoading,
isSending,
className,
}: MentorChatProps) {
const [newMessage, setNewMessage] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
scrollToBottom()
}, [messages])
const handleSend = async () => {
const text = newMessage.trim()
if (!text || isSending) return
setNewMessage('')
await onSendMessage(text)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const formatTime = (date: Date | string) => {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
if (isLoading) {
return (
<div className={cn('flex flex-col gap-3', className)}>
<Skeleton className="h-16 w-3/4" />
<Skeleton className="h-16 w-3/4 ml-auto" />
<Skeleton className="h-16 w-3/4" />
</div>
)
}
return (
<div className={cn('flex flex-col', className)}>
{/* Messages */}
<div className="flex-1 overflow-y-auto max-h-[400px] space-y-3 p-4">
{messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<MessageSquare className="h-10 w-10 mb-3 opacity-50" />
<p className="text-sm font-medium">No messages yet</p>
<p className="text-xs mt-1">Send a message to start the conversation</p>
</div>
) : (
messages.map((msg) => {
const isOwn = msg.sender.id === currentUserId
return (
<div
key={msg.id}
className={cn(
'flex',
isOwn ? 'justify-end' : 'justify-start'
)}
>
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-2.5',
isOwn
? 'bg-primary text-primary-foreground'
: 'bg-muted'
)}
>
{!isOwn && (
<p className={cn(
'text-xs font-medium mb-1',
isOwn ? 'text-primary-foreground/70' : 'text-foreground/70'
)}>
{msg.sender.name || msg.sender.email}
{msg.sender.role === 'MENTOR' && (
<span className="ml-1.5 text-[10px] font-normal opacity-70">
Mentor
</span>
)}
</p>
)}
<p className="text-sm whitespace-pre-wrap break-words">
{msg.message}
</p>
<p
className={cn(
'text-[10px] mt-1',
isOwn
? 'text-primary-foreground/60'
: 'text-muted-foreground'
)}
>
{formatTime(msg.createdAt)}
</p>
</div>
</div>
)
})
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="border-t p-3">
<div className="flex gap-2">
<Textarea
ref={textareaRef}
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
className="min-h-[40px] max-h-[120px] resize-none"
rows={1}
disabled={isSending}
/>
<Button
size="icon"
onClick={handleSend}
disabled={!newMessage.trim() || isSending}
className="shrink-0"
>
<Send className="h-4 w-4" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground mt-1.5">
Press Enter to send, Shift+Enter for new line
</p>
</div>
</div>
)
}

View File

@ -3,6 +3,7 @@
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import type { Route } from 'next' import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { cn, formatRelativeTime } from '@/lib/utils' import { cn, formatRelativeTime } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -207,6 +208,18 @@ function NotificationItem({
export function NotificationBell() { export function NotificationBell() {
const [filter, setFilter] = useState<'all' | 'unread'>('all') const [filter, setFilter] = useState<'all' | 'unread'>('all')
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const pathname = usePathname()
// Derive the role-based path prefix from the current route
const pathPrefix = pathname.startsWith('/admin')
? '/admin'
: pathname.startsWith('/jury')
? '/jury'
: pathname.startsWith('/mentor')
? '/mentor'
: pathname.startsWith('/observer')
? '/observer'
: ''
const { data: countData } = trpc.notification.getUnreadCount.useQuery( const { data: countData } = trpc.notification.getUnreadCount.useQuery(
undefined, undefined,
@ -277,7 +290,7 @@ export function NotificationBell() {
</Button> </Button>
)} )}
<Button variant="ghost" size="icon" asChild> <Button variant="ghost" size="icon" asChild>
<Link href={'/admin/settings' as Route}> <Link href={`${pathPrefix}/settings` as Route}>
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />
<span className="sr-only">Notification settings</span> <span className="sr-only">Notification settings</span>
</Link> </Link>
@ -342,7 +355,7 @@ export function NotificationBell() {
{notifications.length > 0 && ( {notifications.length > 0 && (
<div className="p-2 border-t bg-muted/30"> <div className="p-2 border-t bg-muted/30">
<Button variant="ghost" className="w-full" asChild> <Button variant="ghost" className="w-full" asChild>
<Link href={'/admin/notifications' as Route}>View all notifications</Link> <Link href={`${pathPrefix}/notifications` as Route}>View all notifications</Link>
</Button> </Button>
</div> </div>
)} )}

View File

@ -1,13 +1,14 @@
'use client' 'use client'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { CheckCircle, Circle, Clock } from 'lucide-react' import { CheckCircle, Circle, Clock, XCircle, Trophy } from 'lucide-react'
interface TimelineItem { interface TimelineItem {
status: string status: string
label: string label: string
date: Date | string | null date: Date | string | null
completed: boolean completed: boolean
isTerminal?: boolean
} }
interface StatusTrackerProps { interface StatusTrackerProps {
@ -39,6 +40,8 @@ export function StatusTracker({
const isCurrent = const isCurrent =
isCompleted && !timeline[index + 1]?.completed isCompleted && !timeline[index + 1]?.completed
const isPending = !isCompleted const isPending = !isCompleted
const isRejected = item.status === 'REJECTED' && item.isTerminal
const isWinner = item.status === 'WINNER' && isCompleted
return ( return (
<div key={item.status} className="relative flex gap-4"> <div key={item.status} className="relative flex gap-4">
@ -47,14 +50,26 @@ export function StatusTracker({
<div <div
className={cn( className={cn(
'absolute left-[15px] top-[32px] h-full w-0.5', 'absolute left-[15px] top-[32px] h-full w-0.5',
isCompleted ? 'bg-primary' : 'bg-muted' isRejected
? 'bg-destructive/30'
: isCompleted
? 'bg-primary'
: 'bg-muted'
)} )}
/> />
)} )}
{/* Icon */} {/* Icon */}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center"> <div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center">
{isCompleted ? ( {isRejected ? (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-destructive text-destructive-foreground">
<XCircle className="h-4 w-4" />
</div>
) : isWinner ? (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-yellow-500 text-white">
<Trophy className="h-4 w-4" />
</div>
) : isCompleted ? (
<div <div
className={cn( className={cn(
'flex h-8 w-8 items-center justify-center rounded-full', 'flex h-8 w-8 items-center justify-center rounded-full',
@ -82,23 +97,35 @@ export function StatusTracker({
<p <p
className={cn( className={cn(
'font-medium', 'font-medium',
isPending && 'text-muted-foreground' isRejected && 'text-destructive',
isWinner && 'text-yellow-600',
isPending && !isRejected && 'text-muted-foreground'
)} )}
> >
{item.label} {item.label}
</p> </p>
{isCurrent && ( {isCurrent && !isRejected && !isWinner && (
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full"> <span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
Current Current
</span> </span>
)} )}
{isRejected && (
<span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded-full">
Final
</span>
)}
{isWinner && (
<span className="text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full">
Winner
</span>
)}
</div> </div>
{item.date && ( {item.date && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{formatDate(item.date)} {formatDate(item.date)}
</p> </p>
)} )}
{isPending && !isCurrent && ( {isPending && !isCurrent && !isRejected && (
<p className="text-sm text-muted-foreground">Pending</p> <p className="text-sm text-muted-foreground">Pending</p>
)} )}
</div> </div>

View File

@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
'text-2xl font-semibold leading-none tracking-tight', 'text-lg font-semibold leading-none tracking-tight',
className className
)} )}
{...props} {...props}

59
src/lib/logger.ts Normal file
View File

@ -0,0 +1,59 @@
/**
* Structured Logger Utility
*
* Provides tagged, level-aware logging for consistent output across the application.
* Respects LOG_LEVEL environment variable and NODE_ENV for default levels.
*
* Usage:
* import { logger } from '@/lib/logger'
* logger.info('AI Assignment', 'Processing batch', { batchSize: 15 })
* logger.error('Storage', 'Upload failed', error)
*/
type LogLevel = 'debug' | 'info' | 'warn' | 'error'
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
}
function getConfiguredLevel(): LogLevel {
const envLevel = process.env.LOG_LEVEL?.toLowerCase()
if (envLevel && envLevel in LOG_LEVELS) {
return envLevel as LogLevel
}
return process.env.NODE_ENV === 'production' ? 'warn' : 'debug'
}
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[getConfiguredLevel()]
}
function formatTimestamp(): string {
return new Date().toISOString()
}
export const logger = {
debug: (tag: string, message: string, data?: unknown): void => {
if (shouldLog('debug')) {
console.debug(`${formatTimestamp()} [DEBUG] [${tag}]`, message, data ?? '')
}
},
info: (tag: string, message: string, data?: unknown): void => {
if (shouldLog('info')) {
console.info(`${formatTimestamp()} [INFO] [${tag}]`, message, data ?? '')
}
},
warn: (tag: string, message: string, data?: unknown): void => {
if (shouldLog('warn')) {
console.warn(`${formatTimestamp()} [WARN] [${tag}]`, message, data ?? '')
}
},
error: (tag: string, message: string, data?: unknown): void => {
if (shouldLog('error')) {
console.error(`${formatTimestamp()} [ERROR] [${tag}]`, message, data ?? '')
}
},
}

View File

@ -15,12 +15,18 @@ export const MINIO_PUBLIC_ENDPOINT = process.env.MINIO_PUBLIC_ENDPOINT || MINIO_
function createMinioClient(): Minio.Client { function createMinioClient(): Minio.Client {
const url = new URL(MINIO_ENDPOINT) const url = new URL(MINIO_ENDPOINT)
const accessKey = process.env.MINIO_ACCESS_KEY
const secretKey = process.env.MINIO_SECRET_KEY
if (process.env.NODE_ENV === 'production' && (!accessKey || !secretKey)) {
throw new Error('MINIO_ACCESS_KEY and MINIO_SECRET_KEY environment variables are required in production')
}
return new Minio.Client({ return new Minio.Client({
endPoint: url.hostname, endPoint: url.hostname,
port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80), port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80),
useSSL: url.protocol === 'https:', useSSL: url.protocol === 'https:',
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin', accessKey: accessKey || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin', secretKey: secretKey || 'minioadmin',
}) })
} }
@ -109,12 +115,3 @@ export function generateObjectKey(
return `projects/${projectId}/${timestamp}-${sanitizedName}` return `projects/${projectId}/${timestamp}-${sanitizedName}`
} }
/**
* Get file metadata from MinIO
*/
export async function getObjectInfo(
bucket: string,
objectKey: string
): Promise<Minio.BucketItemStat> {
return minio.statObject(bucket, objectKey)
}

View File

@ -129,10 +129,3 @@ export function isValidImageType(contentType: string): boolean {
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
return validTypes.includes(contentType) return validTypes.includes(contentType)
} }
/**
* Validate image file size (default 5MB max)
*/
export function isValidImageSize(sizeBytes: number, maxMB: number = 5): boolean {
return sizeBytes <= maxMB * 1024 * 1024
}

View File

@ -1,9 +1,15 @@
import { createHmac } from 'crypto' import { createHmac, timingSafeEqual } from 'crypto'
import * as fs from 'fs/promises' import * as fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import type { StorageProvider } from './types' import type { StorageProvider } from './types'
const SECRET_KEY = process.env.NEXTAUTH_SECRET || 'local-storage-secret' function getSecretKey(): string {
const key = process.env.NEXTAUTH_SECRET
if (!key) {
throw new Error('NEXTAUTH_SECRET environment variable is required for local storage signing')
}
return key
}
const DEFAULT_BASE_PATH = './uploads' const DEFAULT_BASE_PATH = './uploads'
/** /**
@ -31,7 +37,7 @@ export class LocalStorageProvider implements StorageProvider {
): string { ): string {
const expiresAt = Math.floor(Date.now() / 1000) + expirySeconds const expiresAt = Math.floor(Date.now() / 1000) + expirySeconds
const payload = `${action}:${key}:${expiresAt}` const payload = `${action}:${key}:${expiresAt}`
const signature = createHmac('sha256', SECRET_KEY) const signature = createHmac('sha256', getSecretKey())
.update(payload) .update(payload)
.digest('hex') .digest('hex')
@ -55,17 +61,29 @@ export class LocalStorageProvider implements StorageProvider {
signature: string signature: string
): boolean { ): boolean {
const payload = `${action}:${key}:${expiresAt}` const payload = `${action}:${key}:${expiresAt}`
const expectedSignature = createHmac('sha256', SECRET_KEY) const expectedSignature = createHmac('sha256', getSecretKey())
.update(payload) .update(payload)
.digest('hex') .digest('hex')
return signature === expectedSignature && expiresAt > Date.now() / 1000 // Use timing-safe comparison to prevent timing attacks
const sigBuffer = Buffer.from(signature, 'hex')
const expectedBuffer = Buffer.from(expectedSignature, 'hex')
if (sigBuffer.length !== expectedBuffer.length) {
return false
}
return timingSafeEqual(sigBuffer, expectedBuffer) && expiresAt > Date.now() / 1000
} }
private getFilePath(key: string): string { private getFilePath(key: string): string {
// Sanitize key to prevent path traversal // Sanitize key to prevent path traversal
const sanitizedKey = key.replace(/\.\./g, '').replace(/^\//, '') const sanitizedKey = key.replace(/\.\./g, '').replace(/^\//, '')
return path.join(this.basePath, sanitizedKey) const resolved = path.resolve(this.basePath, sanitizedKey)
const resolvedBase = path.resolve(this.basePath)
if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
throw new Error('Invalid file path: path traversal detected')
}
return resolved
} }
private async ensureDirectory(filePath: string): Promise<void> { private async ensureDirectory(filePath: string): Promise<void> {

View File

@ -1,11 +1,11 @@
import { z } from 'zod' import { z } from 'zod'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
export const analyticsRouter = router({ export const analyticsRouter = router({
/** /**
* Get score distribution for a round (histogram data) * Get score distribution for a round (histogram data)
*/ */
getScoreDistribution: adminProcedure getScoreDistribution: observerProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({ const evaluations = await ctx.prisma.evaluation.findMany({
@ -50,7 +50,7 @@ export const analyticsRouter = router({
/** /**
* Get evaluation completion over time (timeline data) * Get evaluation completion over time (timeline data)
*/ */
getEvaluationTimeline: adminProcedure getEvaluationTimeline: observerProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({ const evaluations = await ctx.prisma.evaluation.findMany({
@ -96,7 +96,7 @@ export const analyticsRouter = router({
/** /**
* Get juror workload distribution * Get juror workload distribution
*/ */
getJurorWorkload: adminProcedure getJurorWorkload: observerProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({ const assignments = await ctx.prisma.assignment.findMany({
@ -145,14 +145,18 @@ export const analyticsRouter = router({
/** /**
* Get project rankings with average scores * Get project rankings with average scores
*/ */
getProjectRankings: adminProcedure getProjectRankings: observerProcedure
.input(z.object({ roundId: z.string(), limit: z.number().optional() })) .input(z.object({ roundId: z.string(), limit: z.number().optional() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId },
include: { select: {
id: true,
title: true,
teamName: true,
status: true,
assignments: { assignments: {
include: { select: {
evaluation: { evaluation: {
select: { criterionScoresJson: true, status: true }, select: { criterionScoresJson: true, status: true },
}, },
@ -209,7 +213,7 @@ export const analyticsRouter = router({
/** /**
* Get status breakdown (pie chart data) * Get status breakdown (pie chart data)
*/ */
getStatusBreakdown: adminProcedure getStatusBreakdown: observerProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.groupBy({ const projects = await ctx.prisma.project.groupBy({
@ -227,7 +231,7 @@ export const analyticsRouter = router({
/** /**
* Get overview stats for dashboard * Get overview stats for dashboard
*/ */
getOverviewStats: adminProcedure getOverviewStats: observerProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const [ const [
@ -277,7 +281,7 @@ export const analyticsRouter = router({
/** /**
* Get criteria-level score distribution * Get criteria-level score distribution
*/ */
getCriteriaScores: adminProcedure getCriteriaScores: observerProcedure
.input(z.object({ roundId: z.string() })) .input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
// Get active evaluation form for this round // Get active evaluation form for this round
@ -339,7 +343,7 @@ export const analyticsRouter = router({
/** /**
* Get geographic distribution of projects by country * Get geographic distribution of projects by country
*/ */
getGeographicDistribution: adminProcedure getGeographicDistribution: observerProcedure
.input( .input(
z.object({ z.object({
programId: z.string(), programId: z.string(),

View File

@ -2,6 +2,8 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc' import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio' import { getPresignedUrl } from '@/lib/minio'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
// Bucket for applicant submissions // Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions' export const SUBMISSIONS_BUCKET = 'mopc-submissions'
@ -205,16 +207,15 @@ export const applicantRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'CREATE', action: 'CREATE',
entityType: 'Project', entityType: 'Project',
entityId: project.id, entityId: project.id,
detailsJson: { title: input.title, source: 'applicant_portal' }, detailsJson: { title: input.title, source: 'applicant_portal' },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return project return project
@ -231,6 +232,7 @@ export const applicantRouter = router({
fileName: z.string(), fileName: z.string(),
mimeType: z.string(), mimeType: z.string(),
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']), fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
roundId: z.string().optional(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@ -248,6 +250,9 @@ export const applicantRouter = router({
id: input.projectId, id: input.projectId,
submittedByUserId: ctx.user.id, submittedByUserId: ctx.user.id,
}, },
include: {
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
},
}) })
if (!project) { if (!project) {
@ -257,8 +262,38 @@ export const applicantRouter = router({
}) })
} }
// Can't upload if already submitted // Check round upload deadline policy if roundId provided
if (project.submittedAt) { let isLate = false
const targetRoundId = input.roundId || project.roundId
if (targetRoundId) {
const round = input.roundId
? await ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { votingStartAt: true, settingsJson: true },
})
: project.round
if (round) {
const settings = round.settingsJson as Record<string, unknown> | null
const uploadPolicy = settings?.uploadDeadlinePolicy as string | undefined
const now = new Date()
const roundStarted = round.votingStartAt && now > round.votingStartAt
if (roundStarted && uploadPolicy === 'BLOCK') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Uploads are blocked after the round has started',
})
}
if (roundStarted && uploadPolicy === 'ALLOW_LATE') {
isLate = true
}
}
}
// Can't upload if already submitted (unless round allows it)
if (project.submittedAt && !isLate) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project', message: 'Cannot modify a submitted project',
@ -275,6 +310,8 @@ export const applicantRouter = router({
url, url,
bucket: SUBMISSIONS_BUCKET, bucket: SUBMISSIONS_BUCKET,
objectKey, objectKey,
isLate,
roundId: targetRoundId,
} }
}), }),
@ -291,6 +328,8 @@ export const applicantRouter = router({
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']), fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
bucket: z.string(), bucket: z.string(),
objectKey: z.string(), objectKey: z.string(),
roundId: z.string().optional(),
isLate: z.boolean().optional(),
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
@ -317,13 +356,14 @@ export const applicantRouter = router({
}) })
} }
const { projectId, ...fileData } = input const { projectId, roundId, isLate, ...fileData } = input
// Delete existing file of same type if exists // Delete existing file of same type, scoped by roundId if provided
await ctx.prisma.projectFile.deleteMany({ await ctx.prisma.projectFile.deleteMany({
where: { where: {
projectId, projectId,
fileType: input.fileType, fileType: input.fileType,
...(roundId ? { roundId } : {}),
}, },
}) })
@ -332,6 +372,8 @@ export const applicantRouter = router({
data: { data: {
projectId, projectId,
...fileData, ...fileData,
roundId: roundId || null,
isLate: isLate || false,
}, },
}) })
@ -380,6 +422,48 @@ export const applicantRouter = router({
return { success: true } return { success: true }
}), }),
/**
* Get status timeline from ProjectStatusHistory
*/
getStatusTimeline: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify user has access to this project
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
select: { id: true },
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
})
}
const history = await ctx.prisma.projectStatusHistory.findMany({
where: { projectId: input.projectId },
orderBy: { changedAt: 'asc' },
select: {
status: true,
changedAt: true,
changedBy: true,
},
})
return history
}),
/** /**
* Get submission status timeline * Get submission status timeline
*/ */
@ -412,6 +496,9 @@ export const applicantRouter = router({
}, },
}, },
}, },
wonAwards: {
select: { id: true, name: true },
},
}, },
}) })
@ -425,40 +512,89 @@ export const applicantRouter = router({
// Get the project status // Get the project status
const currentStatus = project.status ?? 'SUBMITTED' const currentStatus = project.status ?? 'SUBMITTED'
// Build timeline // Fetch actual status history
const statusHistory = await ctx.prisma.projectStatusHistory.findMany({
where: { projectId: input.projectId },
orderBy: { changedAt: 'asc' },
select: { status: true, changedAt: true },
})
// Build a map of status -> earliest changedAt
const statusDateMap = new Map<string, Date>()
for (const entry of statusHistory) {
if (!statusDateMap.has(entry.status)) {
statusDateMap.set(entry.status, entry.changedAt)
}
}
const isRejected = currentStatus === 'REJECTED'
const hasWonAward = project.wonAwards.length > 0
// Build timeline - handle REJECTED as terminal state
const timeline = [ const timeline = [
{ {
status: 'CREATED', status: 'CREATED',
label: 'Application Started', label: 'Application Started',
date: project.createdAt, date: project.createdAt,
completed: true, completed: true,
isTerminal: false,
}, },
{ {
status: 'SUBMITTED', status: 'SUBMITTED',
label: 'Application Submitted', label: 'Application Submitted',
date: project.submittedAt, date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
completed: !!project.submittedAt, completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
isTerminal: false,
}, },
{ {
status: 'UNDER_REVIEW', status: 'UNDER_REVIEW',
label: 'Under Review', label: 'Under Review',
date: currentStatus === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null, date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus), (currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
}, completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
{ isTerminal: false,
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: null, // Would need status change tracking
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
},
{
status: 'FINALIST',
label: 'Finalist',
date: null,
completed: ['FINALIST', 'WINNER'].includes(currentStatus),
}, },
] ]
if (isRejected) {
// For rejected projects, show REJECTED as the terminal red step
timeline.push({
status: 'REJECTED',
label: 'Not Selected',
date: statusDateMap.get('REJECTED') || null,
completed: true,
isTerminal: true,
})
} else {
// Normal progression
timeline.push(
{
status: 'SEMIFINALIST',
label: 'Semi-finalist',
date: statusDateMap.get('SEMIFINALIST') || null,
completed: ['SEMIFINALIST', 'FINALIST'].includes(currentStatus) || hasWonAward,
isTerminal: false,
},
{
status: 'FINALIST',
label: 'Finalist',
date: statusDateMap.get('FINALIST') || null,
completed: currentStatus === 'FINALIST' || hasWonAward,
isTerminal: false,
},
)
if (hasWonAward) {
timeline.push({
status: 'WINNER',
label: `Winner${project.wonAwards.length > 0 ? ` - ${project.wonAwards[0].name}` : ''}`,
date: null,
completed: true,
isTerminal: false,
})
}
}
return { return {
project, project,
timeline, timeline,
@ -714,4 +850,130 @@ export const applicantRouter = router({
return { success: true } return { success: true }
}), }),
/**
* Send a message to the assigned mentor
*/
sendMentorMessage: protectedProcedure
.input(
z.object({
projectId: z.string(),
message: z.string().min(1).max(5000),
})
)
.mutation(async ({ ctx, input }) => {
// Verify user is part of this project team
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
include: {
mentorAssignment: { select: { mentorId: true } },
},
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found or you do not have access',
})
}
if (!project.mentorAssignment) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No mentor assigned to this project',
})
}
const mentorMessage = await ctx.prisma.mentorMessage.create({
data: {
projectId: input.projectId,
senderId: ctx.user.id,
message: input.message,
},
include: {
sender: {
select: { id: true, name: true, email: true },
},
},
})
// Notify the mentor
await createNotification({
userId: project.mentorAssignment.mentorId,
type: 'MENTOR_MESSAGE',
title: 'New Message',
message: `${ctx.user.name || 'Team member'} sent a message about "${project.title}"`,
linkUrl: `/mentor/projects/${input.projectId}`,
linkLabel: 'View Message',
priority: 'normal',
metadata: {
projectId: input.projectId,
projectName: project.title,
},
})
return mentorMessage
}),
/**
* Get mentor messages for a project (applicant side)
*/
getMentorMessages: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify user is part of this project team
const project = await ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{
teamMembers: {
some: { userId: ctx.user.id },
},
},
],
},
select: { id: true },
})
if (!project) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found or you do not have access',
})
}
const messages = await ctx.prisma.mentorMessage.findMany({
where: { projectId: input.projectId },
include: {
sender: {
select: { id: true, name: true, email: true, role: true },
},
},
orderBy: { createdAt: 'asc' },
})
// Mark unread messages from mentor as read
await ctx.prisma.mentorMessage.updateMany({
where: {
projectId: input.projectId,
senderId: { not: ctx.user.id },
isRead: false,
},
data: { isRead: true },
})
return messages
}),
}) })

View File

@ -7,6 +7,8 @@ import {
notifyAdmins, notifyAdmins,
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { checkRateLimit } from '@/lib/rate-limit'
import { logAudit } from '@/server/utils/audit'
// Zod schemas for the application form // Zod schemas for the application form
const teamMemberSchema = z.object({ const teamMemberSchema = z.object({
@ -153,6 +155,16 @@ export const applicationRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Stricter rate limit for application submissions: 5 per hour per IP
const ip = ctx.ip || 'unknown'
const submitRateLimit = checkRateLimit(`app-submit:${ip}`, 5, 60 * 60 * 1000)
if (!submitRateLimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Too many application submissions. Please try again later.',
})
}
const { roundId, data } = input const { roundId, data } = input
// Verify round exists and is open // Verify round exists and is open
@ -288,20 +300,19 @@ export const applicationRouter = router({
} }
// Create audit log // Create audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: user.id, userId: user.id,
action: 'CREATE', action: 'CREATE',
entityType: 'Project', entityType: 'Project',
entityId: project.id, entityId: project.id,
detailsJson: { detailsJson: {
source: 'public_application_form', source: 'public_application_form',
title: data.projectName, title: data.projectName,
category: data.competitionCategory, category: data.competitionCategory,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
// Notify applicant of successful submission // Notify applicant of successful submission
@ -351,6 +362,16 @@ export const applicationRouter = router({
}) })
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
// Rate limit to prevent email enumeration
const ip = ctx.ip || 'unknown'
const emailCheckLimit = checkRateLimit(`email-check:${ip}`, 20, 15 * 60 * 1000)
if (!emailCheckLimit.success) {
throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Too many requests. Please try again later.',
})
}
const existing = await ctx.prisma.project.findFirst({ const existing = await ctx.prisma.project.findFirst({
where: { where: {
roundId: input.roundId, roundId: input.roundId,

View File

@ -15,6 +15,7 @@ import {
notifyAdmins, notifyAdmins,
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
// Background job execution function // Background job execution function
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) { async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
@ -355,16 +356,15 @@ export const assignmentRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'CREATE', action: 'CREATE',
entityType: 'Assignment', entityType: 'Assignment',
entityId: assignment.id, entityId: assignment.id,
detailsJson: input, detailsJson: input,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
// Send notification to the assigned jury member // Send notification to the assigned jury member
@ -434,15 +434,14 @@ export const assignmentRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'BULK_CREATE', action: 'BULK_CREATE',
entityType: 'Assignment', entityType: 'Assignment',
detailsJson: { count: result.count }, detailsJson: { count: result.count },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
// Send notifications to assigned jury members (grouped by user) // Send notifications to assigned jury members (grouped by user)
@ -472,10 +471,19 @@ export const assignmentRouter = router({
}) })
: undefined : undefined
// Send batch notification to each user // Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) { for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({ const existing = usersByProjectCount.get(projectCount) || []
userId, existing.push(userId)
usersByProjectCount.set(projectCount, existing)
}
// Send bulk notifications for each project count group
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED, type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`, title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`, message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
@ -490,7 +498,11 @@ export const assignmentRouter = router({
} }
} }
return { created: result.count } return {
created: result.count,
requested: input.assignments.length,
skipped: input.assignments.length - result.count,
}
}), }),
/** /**
@ -504,19 +516,18 @@ export const assignmentRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'DELETE', action: 'DELETE',
entityType: 'Assignment', entityType: 'Assignment',
entityId: input.id, entityId: input.id,
detailsJson: { detailsJson: {
userId: assignment.userId, userId: assignment.userId,
projectId: assignment.projectId, projectId: assignment.projectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return assignment return assignment
@ -533,6 +544,7 @@ export const assignmentRouter = router({
completedAssignments, completedAssignments,
assignmentsByUser, assignmentsByUser,
projectCoverage, projectCoverage,
round,
] = await Promise.all([ ] = await Promise.all([
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }), ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({ ctx.prisma.assignment.count({
@ -551,13 +563,12 @@ export const assignmentRouter = router({
_count: { select: { assignments: true } }, _count: { select: { assignments: true } },
}, },
}), }),
ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { requiredReviews: true },
}),
]) ])
const round = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { requiredReviews: true },
})
const projectsWithFullCoverage = projectCoverage.filter( const projectsWithFullCoverage = projectCoverage.filter(
(p) => p._count.assignments >= round.requiredReviews (p) => p._count.assignments >= round.requiredReviews
).length ).length
@ -845,19 +856,18 @@ export const assignmentRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS', action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
entityType: 'Assignment', entityType: 'Assignment',
detailsJson: { detailsJson: {
roundId: input.roundId, roundId: input.roundId,
count: created.count, count: created.count,
usedAI: input.usedAI, usedAI: input.usedAI,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
// Send notifications to assigned jury members // Send notifications to assigned jury members
@ -884,9 +894,19 @@ export const assignmentRouter = router({
}) })
: undefined : undefined
// Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) { for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({ const existing = usersByProjectCount.get(projectCount) || []
userId, existing.push(userId)
usersByProjectCount.set(projectCount, existing)
}
// Send bulk notifications for each project count group
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED, type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`, title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`, message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
@ -934,18 +954,17 @@ export const assignmentRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'APPLY_SUGGESTIONS', action: 'APPLY_SUGGESTIONS',
entityType: 'Assignment', entityType: 'Assignment',
detailsJson: { detailsJson: {
roundId: input.roundId, roundId: input.roundId,
count: created.count, count: created.count,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
// Send notifications to assigned jury members // Send notifications to assigned jury members
@ -972,9 +991,19 @@ export const assignmentRouter = router({
}) })
: undefined : undefined
// Group users by project count so we can send bulk notifications per group
const usersByProjectCount = new Map<number, string[]>()
for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) { for (const [userId, projectCount] of Object.entries(userAssignmentCounts)) {
await createNotification({ const existing = usersByProjectCount.get(projectCount) || []
userId, existing.push(userId)
usersByProjectCount.set(projectCount, existing)
}
// Send bulk notifications for each project count group
for (const [projectCount, userIds] of usersByProjectCount) {
if (userIds.length === 0) continue
await createBulkNotifications({
userIds,
type: NotificationTypes.BATCH_ASSIGNED, type: NotificationTypes.BATCH_ASSIGNED,
title: `${projectCount} Projects Assigned`, title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`, message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
@ -1038,7 +1067,7 @@ export const assignmentRouter = router({
/** /**
* Get AI assignment job status (for polling) * Get AI assignment job status (for polling)
*/ */
getAIAssignmentJobStatus: protectedProcedure getAIAssignmentJobStatus: adminProcedure
.input(z.object({ jobId: z.string() })) .input(z.object({ jobId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({ const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({

View File

@ -1,14 +1,43 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure } from '../trpc' import { router, protectedProcedure } from '../trpc'
import { generateAvatarKey, type StorageProviderType } from '@/lib/storage'
import { import {
getStorageProviderWithType, getImageUploadUrl,
createStorageProvider, confirmImageUpload,
generateAvatarKey, getImageUrl,
getContentType, deleteImage,
isValidImageType, type ImageUploadConfig,
type StorageProviderType, } from '../utils/image-upload'
} from '@/lib/storage'
type AvatarSelect = {
profileImageKey: string | null
profileImageProvider: string | null
}
const avatarConfig: ImageUploadConfig<AvatarSelect> = {
label: 'avatar',
generateKey: generateAvatarKey,
findCurrent: (prisma, entityId) =>
prisma.user.findUnique({
where: { id: entityId },
select: { profileImageKey: true, profileImageProvider: true },
}),
getImageKey: (record) => record.profileImageKey,
getProviderType: (record) =>
(record.profileImageProvider as StorageProviderType) || 's3',
setImage: (prisma, entityId, key, providerType) =>
prisma.user.update({
where: { id: entityId },
data: { profileImageKey: key, profileImageProvider: providerType },
}),
clearImage: (prisma, entityId) =>
prisma.user.update({
where: { id: entityId },
data: { profileImageKey: null, profileImageProvider: null },
}),
auditEntityType: 'User',
auditFieldName: 'profileImageKey',
}
export const avatarRouter = router({ export const avatarRouter = router({
/** /**
@ -22,23 +51,12 @@ export const avatarRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Validate content type return getImageUploadUrl(
if (!isValidImageType(input.contentType)) { ctx.user.id,
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP' }) input.fileName,
} input.contentType,
generateAvatarKey
const userId = ctx.user.id )
const key = generateAvatarKey(userId, input.fileName)
const contentType = getContentType(input.fileName)
const { provider, providerType } = await getStorageProviderWithType()
const uploadUrl = await provider.getUploadUrl(key, contentType)
return {
uploadUrl,
key,
providerType, // Return so client can pass it back on confirm
}
}), }),
/** /**
@ -54,38 +72,15 @@ export const avatarRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = ctx.user.id const userId = ctx.user.id
// Use the provider that was used for upload await confirmImageUpload(ctx.prisma, avatarConfig, userId, input.key, input.providerType, {
const provider = createStorageProvider(input.providerType) userId: ctx.user.id,
const exists = await provider.objectExists(input.key) ip: ctx.ip,
if (!exists) { userAgent: ctx.userAgent,
throw new TRPCError({ code: 'NOT_FOUND', message: 'Upload not found. Please try uploading again.' })
}
// Delete old avatar if exists (from its original provider)
const currentUser = await ctx.prisma.user.findUnique({
where: { id: userId },
select: { profileImageKey: true, profileImageProvider: true },
}) })
if (currentUser?.profileImageKey) { // Return the updated user fields to match original API contract
try { const user = await ctx.prisma.user.findUnique({
const oldProvider = createStorageProvider(
(currentUser.profileImageProvider as StorageProviderType) || 's3'
)
await oldProvider.deleteObject(currentUser.profileImageKey)
} catch (error) {
// Log but don't fail if old avatar deletion fails
console.warn('Failed to delete old avatar:', error)
}
}
// Update user with new avatar key and provider
const user = await ctx.prisma.user.update({
where: { id: userId }, where: { id: userId },
data: {
profileImageKey: input.key,
profileImageProvider: input.providerType,
},
select: { select: {
id: true, id: true,
profileImageKey: true, profileImageKey: true,
@ -93,23 +88,6 @@ export const avatarRouter = router({
}, },
}) })
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'User',
entityId: userId,
detailsJson: {
field: 'profileImageKey',
newValue: input.key,
provider: input.providerType,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return user return user
}), }),
@ -117,71 +95,17 @@ export const avatarRouter = router({
* Get the current user's avatar URL * Get the current user's avatar URL
*/ */
getUrl: protectedProcedure.query(async ({ ctx }) => { getUrl: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.user.id return getImageUrl(ctx.prisma, avatarConfig, ctx.user.id)
const user = await ctx.prisma.user.findUnique({
where: { id: userId },
select: { profileImageKey: true, profileImageProvider: true },
})
if (!user?.profileImageKey) {
return null
}
// Use the provider that was used when the file was stored
const providerType = (user.profileImageProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
const url = await provider.getDownloadUrl(user.profileImageKey)
return url
}), }),
/** /**
* Delete the current user's avatar * Delete the current user's avatar
*/ */
delete: protectedProcedure.mutation(async ({ ctx }) => { delete: protectedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.user.id return deleteImage(ctx.prisma, avatarConfig, ctx.user.id, {
userId: ctx.user.id,
const user = await ctx.prisma.user.findUnique({ ip: ctx.ip,
where: { id: userId }, userAgent: ctx.userAgent,
select: { profileImageKey: true, profileImageProvider: true },
}) })
if (!user?.profileImageKey) {
return { success: true }
}
// Delete from the provider that was used when the file was stored
const providerType = (user.profileImageProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
try {
await provider.deleteObject(user.profileImageKey)
} catch (error) {
console.warn('Failed to delete avatar from storage:', error)
}
// Update user - clear both key and provider
await ctx.prisma.user.update({
where: { id: userId },
data: {
profileImageKey: null,
profileImageProvider: null,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'User',
entityId: userId,
detailsJson: { field: 'profileImageKey' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}), }),
}) })

View File

@ -1,6 +1,10 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
import { notifyAdmins, NotificationTypes } from '../services/in-app-notification'
import { processEvaluationReminders } from '../services/evaluation-reminders'
import { generateSummary } from '@/server/services/ai-evaluation-summary'
export const evaluationRouter = router({ export const evaluationRouter = router({
/** /**
@ -88,7 +92,7 @@ export const evaluationRouter = router({
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
criterionScoresJson: z.record(z.number()).optional(), criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])).optional(),
globalScore: z.number().int().min(1).max(10).optional().nullable(), globalScore: z.number().int().min(1).max(10).optional().nullable(),
binaryDecision: z.boolean().optional().nullable(), binaryDecision: z.boolean().optional().nullable(),
feedbackText: z.string().optional().nullable(), feedbackText: z.string().optional().nullable(),
@ -133,7 +137,7 @@ export const evaluationRouter = router({
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
criterionScoresJson: z.record(z.number()), criterionScoresJson: z.record(z.union([z.number(), z.string(), z.boolean()])),
globalScore: z.number().int().min(1).max(10), globalScore: z.number().int().min(1).max(10),
binaryDecision: z.boolean(), binaryDecision: z.boolean(),
feedbackText: z.string().min(10), feedbackText: z.string().min(10),
@ -196,38 +200,37 @@ export const evaluationRouter = router({
}) })
} }
// Submit // Submit evaluation and mark assignment as completed atomically
const updated = await ctx.prisma.evaluation.update({ const [updated] = await ctx.prisma.$transaction([
where: { id }, ctx.prisma.evaluation.update({
data: { where: { id },
...data, data: {
status: 'SUBMITTED', ...data,
submittedAt: now, status: 'SUBMITTED',
}, submittedAt: now,
}) },
}),
// Mark assignment as completed ctx.prisma.assignment.update({
await ctx.prisma.assignment.update({ where: { id: evaluation.assignmentId },
where: { id: evaluation.assignmentId }, data: { isCompleted: true },
data: { isCompleted: true }, }),
}) ])
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'EVALUATION_SUBMITTED', action: 'EVALUATION_SUBMITTED',
entityType: 'Evaluation', entityType: 'Evaluation',
entityId: id, entityId: id,
detailsJson: { detailsJson: {
projectId: evaluation.assignment.projectId, projectId: evaluation.assignment.projectId,
roundId: evaluation.assignment.roundId, roundId: evaluation.assignment.roundId,
globalScore: data.globalScore, globalScore: data.globalScore,
binaryDecision: data.binaryDecision, binaryDecision: data.binaryDecision,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return updated return updated
@ -325,4 +328,297 @@ export const evaluationRouter = router({
orderBy: { submittedAt: 'desc' }, orderBy: { submittedAt: 'desc' },
}) })
}), }),
// =========================================================================
// Conflict of Interest (COI) Endpoints
// =========================================================================
/**
* Declare a conflict of interest for an assignment
*/
declareCOI: protectedProcedure
.input(
z.object({
assignmentId: z.string(),
hasConflict: z.boolean(),
conflictType: z.string().optional(),
description: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
// Look up the assignment to get projectId, roundId, userId
const assignment = await ctx.prisma.assignment.findUniqueOrThrow({
where: { id: input.assignmentId },
include: {
project: { select: { title: true } },
round: { select: { name: true } },
},
})
// Verify ownership
if (assignment.userId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' })
}
// Upsert COI record
const coi = await ctx.prisma.conflictOfInterest.upsert({
where: { assignmentId: input.assignmentId },
create: {
assignmentId: input.assignmentId,
userId: ctx.user.id,
projectId: assignment.projectId,
roundId: assignment.roundId,
hasConflict: input.hasConflict,
conflictType: input.hasConflict ? input.conflictType : null,
description: input.hasConflict ? input.description : null,
},
update: {
hasConflict: input.hasConflict,
conflictType: input.hasConflict ? input.conflictType : null,
description: input.hasConflict ? input.description : null,
declaredAt: new Date(),
},
})
// Notify admins if conflict declared
if (input.hasConflict) {
await notifyAdmins({
type: NotificationTypes.JURY_INACTIVE,
title: 'Conflict of Interest Declared',
message: `${ctx.user.name || ctx.user.email} declared a conflict of interest (${input.conflictType || 'unspecified'}) for project "${assignment.project.title}" in ${assignment.round.name}.`,
linkUrl: `/admin/rounds/${assignment.roundId}/coi`,
linkLabel: 'Review COI',
priority: 'high',
metadata: {
assignmentId: input.assignmentId,
userId: ctx.user.id,
projectId: assignment.projectId,
roundId: assignment.roundId,
conflictType: input.conflictType,
},
})
}
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COI_DECLARED',
entityType: 'ConflictOfInterest',
entityId: coi.id,
detailsJson: {
assignmentId: input.assignmentId,
projectId: assignment.projectId,
roundId: assignment.roundId,
hasConflict: input.hasConflict,
conflictType: input.conflictType,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return coi
}),
/**
* Get COI status for an assignment
*/
getCOIStatus: protectedProcedure
.input(z.object({ assignmentId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.conflictOfInterest.findUnique({
where: { assignmentId: input.assignmentId },
})
}),
/**
* List COI declarations for a round (admin only)
*/
listCOIByRound: adminProcedure
.input(
z.object({
roundId: z.string(),
hasConflictOnly: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.conflictOfInterest.findMany({
where: {
roundId: input.roundId,
...(input.hasConflictOnly && { hasConflict: true }),
},
include: {
user: { select: { id: true, name: true, email: true } },
assignment: {
include: {
project: { select: { id: true, title: true } },
},
},
reviewedBy: { select: { id: true, name: true, email: true } },
},
orderBy: { declaredAt: 'desc' },
})
}),
/**
* Review a COI declaration (admin only)
*/
reviewCOI: adminProcedure
.input(
z.object({
id: z.string(),
reviewAction: z.enum(['cleared', 'reassigned', 'noted']),
})
)
.mutation(async ({ ctx, input }) => {
const coi = await ctx.prisma.conflictOfInterest.update({
where: { id: input.id },
data: {
reviewedById: ctx.user.id,
reviewedAt: new Date(),
reviewAction: input.reviewAction,
},
})
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'COI_REVIEWED',
entityType: 'ConflictOfInterest',
entityId: input.id,
detailsJson: {
reviewAction: input.reviewAction,
assignmentId: coi.assignmentId,
userId: coi.userId,
projectId: coi.projectId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return coi
}),
// =========================================================================
// Reminder Triggers
// =========================================================================
/**
* Manually trigger reminder check for a specific round (admin only)
*/
triggerReminders: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await processEvaluationReminders(input.roundId)
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REMINDERS_TRIGGERED',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
sent: result.sent,
errors: result.errors,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return result
}),
// =========================================================================
// AI Evaluation Summary Endpoints
// =========================================================================
/**
* Generate an AI-powered evaluation summary for a project (admin only)
*/
generateSummary: adminProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
return generateSummary({
projectId: input.projectId,
roundId: input.roundId,
userId: ctx.user.id,
prisma: ctx.prisma,
})
}),
/**
* Get an existing evaluation summary for a project (admin only)
*/
getSummary: adminProcedure
.input(
z.object({
projectId: z.string(),
roundId: z.string(),
})
)
.query(async ({ ctx, input }) => {
return ctx.prisma.evaluationSummary.findUnique({
where: {
projectId_roundId: {
projectId: input.projectId,
roundId: input.roundId,
},
},
})
}),
/**
* Generate summaries for all projects in a round with submitted evaluations (admin only)
*/
generateBulkSummaries: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Find all projects in the round with at least 1 submitted evaluation
const projects = await ctx.prisma.project.findMany({
where: {
roundId: input.roundId,
assignments: {
some: {
evaluation: {
status: 'SUBMITTED',
},
},
},
},
select: { id: true },
})
let generated = 0
const errors: Array<{ projectId: string; error: string }> = []
// Generate summaries sequentially to avoid rate limits
for (const project of projects) {
try {
await generateSummary({
projectId: project.id,
roundId: input.roundId,
userId: ctx.user.id,
prisma: ctx.prisma,
})
generated++
} catch (error) {
errors.push({
projectId: project.id,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
return {
total: projects.length,
generated,
errors,
}
}),
}) })

View File

@ -1,5 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
import { router, adminProcedure } from '../trpc' import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const exportRouter = router({ export const exportRouter = router({
/** /**
@ -69,15 +70,14 @@ export const exportRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'EXPORT', action: 'EXPORT',
entityType: 'Evaluation', entityType: 'Evaluation',
detailsJson: { roundId: input.roundId, count: data.length }, detailsJson: { roundId: input.roundId, count: data.length },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { return {
@ -154,15 +154,14 @@ export const exportRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'EXPORT', action: 'EXPORT',
entityType: 'ProjectScores', entityType: 'ProjectScores',
detailsJson: { roundId: input.roundId, count: data.length }, detailsJson: { roundId: input.roundId, count: data.length },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { return {
@ -229,6 +228,103 @@ export const exportRouter = router({
} }
}), }),
/**
* Export filtering results as CSV data
*/
filteringResults: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const results = await ctx.prisma.filteringResult.findMany({
where: { roundId: input.roundId },
include: {
project: {
select: {
title: true,
teamName: true,
competitionCategory: true,
country: true,
oceanIssue: true,
tags: true,
},
},
},
orderBy: { project: { title: 'asc' } },
})
// Collect all unique AI screening keys across all results
const aiKeys = new Set<string>()
results.forEach((r) => {
if (r.aiScreeningJson && typeof r.aiScreeningJson === 'object') {
const screening = r.aiScreeningJson as Record<string, Record<string, unknown>>
for (const ruleResult of Object.values(screening)) {
if (ruleResult && typeof ruleResult === 'object') {
Object.keys(ruleResult).forEach((k) => aiKeys.add(k))
}
}
}
})
const sortedAiKeys = Array.from(aiKeys).sort()
const data = results.map((r) => {
// Flatten AI screening - take first rule result's values
const aiFlat: Record<string, unknown> = {}
if (r.aiScreeningJson && typeof r.aiScreeningJson === 'object') {
const screening = r.aiScreeningJson as Record<string, Record<string, unknown>>
const firstEntry = Object.values(screening)[0]
if (firstEntry && typeof firstEntry === 'object') {
for (const key of sortedAiKeys) {
const val = firstEntry[key]
aiFlat[`ai_${key}`] = val !== undefined ? String(val) : ''
}
}
}
return {
projectTitle: r.project.title,
teamName: r.project.teamName ?? '',
category: r.project.competitionCategory ?? '',
country: r.project.country ?? '',
oceanIssue: r.project.oceanIssue ?? '',
tags: r.project.tags.join(', '),
outcome: r.outcome,
finalOutcome: r.finalOutcome ?? '',
overrideReason: r.overrideReason ?? '',
...aiFlat,
}
})
// Build columns list
const baseColumns = [
'projectTitle',
'teamName',
'category',
'country',
'oceanIssue',
'tags',
'outcome',
'finalOutcome',
'overrideReason',
]
const aiColumns = sortedAiKeys.map((k) => `ai_${k}`)
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'FilteringResult',
detailsJson: { roundId: input.roundId, count: data.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {
data,
columns: [...baseColumns, ...aiColumns],
}
}),
/** /**
* Export audit logs as CSV data * Export audit logs as CSV data
*/ */

View File

@ -1,7 +1,8 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey, BUCKET_NAME } from '@/lib/minio' import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
import { logAudit } from '../utils/audit'
export const fileRouter = router({ export const fileRouter = router({
/** /**
@ -19,10 +20,14 @@ export const fileRouter = router({
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) { if (!isAdmin) {
// Find the file record to get the project // Find the file record to get the project and round info
const file = await ctx.prisma.projectFile.findFirst({ const file = await ctx.prisma.projectFile.findFirst({
where: { bucket: input.bucket, objectKey: input.objectKey }, where: { bucket: input.bucket, objectKey: input.objectKey },
select: { projectId: true }, select: {
projectId: true,
roundId: true,
round: { select: { programId: true, sortOrder: true } },
},
}) })
if (!file) { if (!file) {
@ -32,39 +37,69 @@ export const fileRouter = router({
}) })
} }
// Check if user is assigned as jury or mentor for this project // Check if user is assigned as jury, mentor, or team member for this project
const [juryAssignment, mentorAssignment] = await Promise.all([ const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({ ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: file.projectId }, where: { userId: ctx.user.id, projectId: file.projectId },
select: { id: true }, select: { id: true, roundId: true },
}), }),
ctx.prisma.mentorAssignment.findFirst({ ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: file.projectId }, where: { mentorId: ctx.user.id, projectId: file.projectId },
select: { id: true }, select: { id: true },
}), }),
ctx.prisma.project.findFirst({
where: {
id: file.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
]) ])
if (!juryAssignment && !mentorAssignment) { if (!juryAssignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({ throw new TRPCError({
code: 'FORBIDDEN', code: 'FORBIDDEN',
message: 'You do not have access to this file', message: 'You do not have access to this file',
}) })
} }
// For jury members, verify round-scoped access:
// File must belong to the jury's assigned round or a prior round in the same program
if (juryAssignment && !mentorAssignment && !teamMembership && file.roundId && file.round) {
const assignedRound = await ctx.prisma.round.findUnique({
where: { id: juryAssignment.roundId },
select: { programId: true, sortOrder: true },
})
if (assignedRound) {
const sameProgram = assignedRound.programId === file.round.programId
const priorOrSameRound = file.round.sortOrder <= assignedRound.sortOrder
if (!sameProgram || !priorOrSameRound) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this file',
})
}
}
}
} }
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
// Log file access // Log file access
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'FILE_DOWNLOADED', action: 'FILE_DOWNLOADED',
entityType: 'ProjectFile', entityType: 'ProjectFile',
detailsJson: { bucket: input.bucket, objectKey: input.objectKey }, detailsJson: { bucket: input.bucket, objectKey: input.objectKey },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
}).catch(() => {})
return { url } return { url }
}), }),
@ -83,6 +118,16 @@ export const fileRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Block dangerous file extensions
const dangerousExtensions = ['.exe', '.sh', '.bat', '.cmd', '.ps1', '.php', '.jsp', '.cgi', '.dll', '.msi']
const ext = input.fileName.toLowerCase().slice(input.fileName.lastIndexOf('.'))
if (dangerousExtensions.includes(ext)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `File type "${ext}" is not allowed`,
})
}
const bucket = BUCKET_NAME const bucket = BUCKET_NAME
const objectKey = generateObjectKey(input.projectId, input.fileName) const objectKey = generateObjectKey(input.projectId, input.fileName)
@ -102,20 +147,19 @@ export const fileRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPLOAD_FILE', action: 'UPLOAD_FILE',
entityType: 'ProjectFile', entityType: 'ProjectFile',
entityId: file.id, entityId: file.id,
detailsJson: { detailsJson: {
projectId: input.projectId, projectId: input.projectId,
fileName: input.fileName, fileName: input.fileName,
fileType: input.fileType, fileType: input.fileType,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return { return {
@ -147,24 +191,29 @@ export const fileRouter = router({
where: { id: input.id }, where: { id: input.id },
}) })
// Note: Actual MinIO deletion could be done here or via background job // Delete actual storage object (best-effort, don't fail the operation)
// For now, we just delete the database record try {
if (file.bucket && file.objectKey) {
await deleteObject(file.bucket, file.objectKey)
}
} catch (error) {
console.error(`[File] Failed to delete storage object ${file.objectKey}:`, error)
}
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'DELETE_FILE', action: 'DELETE_FILE',
entityType: 'ProjectFile', entityType: 'ProjectFile',
entityId: input.id, entityId: input.id,
detailsJson: { detailsJson: {
fileName: file.fileName, fileName: file.fileName,
bucket: file.bucket, bucket: file.bucket,
objectKey: file.objectKey, objectKey: file.objectKey,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return file return file
@ -175,12 +224,15 @@ export const fileRouter = router({
* Checks that the user is authorized to view the project's files * Checks that the user is authorized to view the project's files
*/ */
listByProject: protectedProcedure listByProject: protectedProcedure
.input(z.object({ projectId: z.string() })) .input(z.object({
projectId: z.string(),
roundId: z.string().optional(),
}))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role) const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) { if (!isAdmin) {
const [juryAssignment, mentorAssignment] = await Promise.all([ const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({ ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId }, where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true }, select: { id: true },
@ -189,9 +241,19 @@ export const fileRouter = router({
where: { mentorId: ctx.user.id, projectId: input.projectId }, where: { mentorId: ctx.user.id, projectId: input.projectId },
select: { id: true }, select: { id: true },
}), }),
ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
]) ])
if (!juryAssignment && !mentorAssignment) { if (!juryAssignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({ throw new TRPCError({
code: 'FORBIDDEN', code: 'FORBIDDEN',
message: 'You do not have access to this project\'s files', message: 'You do not have access to this project\'s files',
@ -199,9 +261,127 @@ export const fileRouter = router({
} }
} }
const where: Record<string, unknown> = { projectId: input.projectId }
if (input.roundId) {
where.roundId = input.roundId
}
return ctx.prisma.projectFile.findMany({ return ctx.prisma.projectFile.findMany({
where: { projectId: input.projectId }, where,
include: {
round: { select: { id: true, name: true, sortOrder: true } },
},
orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }], orderBy: [{ fileType: 'asc' }, { createdAt: 'asc' }],
}) })
}), }),
/**
* List files for a project grouped by round
* Returns files for the specified round + all prior rounds in the same program
*/
listByProjectForRound: protectedProcedure
.input(z.object({
projectId: z.string(),
roundId: z.string(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true, roundId: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: input.projectId },
select: { id: true },
}),
ctx.prisma.project.findFirst({
where: {
id: input.projectId,
OR: [
{ submittedByUserId: ctx.user.id },
{ teamMembers: { some: { userId: ctx.user.id } } },
],
},
select: { id: true },
}),
])
if (!juryAssignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have access to this project\'s files',
})
}
}
// Get the target round with its program and sortOrder
const targetRound = await ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { programId: true, sortOrder: true },
})
// Get all rounds in the same program with sortOrder <= target
const eligibleRounds = await ctx.prisma.round.findMany({
where: {
programId: targetRound.programId,
sortOrder: { lte: targetRound.sortOrder },
},
select: { id: true, name: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
})
const eligibleRoundIds = eligibleRounds.map((r) => r.id)
// Get files for these rounds (or files with no roundId)
const files = await ctx.prisma.projectFile.findMany({
where: {
projectId: input.projectId,
OR: [
{ roundId: { in: eligibleRoundIds } },
{ roundId: null },
],
},
include: {
round: { select: { id: true, name: true, sortOrder: true } },
},
orderBy: [{ createdAt: 'asc' }],
})
// Group by round
const grouped: Array<{
roundId: string | null
roundName: string
sortOrder: number
files: typeof files
}> = []
// Add "General" group for files with no round
const generalFiles = files.filter((f) => !f.roundId)
if (generalFiles.length > 0) {
grouped.push({
roundId: null,
roundName: 'General',
sortOrder: -1,
files: generalFiles,
})
}
// Add groups for each round
for (const round of eligibleRounds) {
const roundFiles = files.filter((f) => f.roundId === round.id)
if (roundFiles.length > 0) {
grouped.push({
roundId: round.id,
roundName: round.name,
sortOrder: round.sortOrder,
files: roundFiles,
})
}
}
return grouped
}),
}) })

View File

@ -499,36 +499,40 @@ export const filteringRouter = router({
// Execute rules // Execute rules
const results = await executeFilteringRules(rules, projects) const results = await executeFilteringRules(rules, projects)
// Upsert results // Upsert results in batches to avoid long-held locks
await ctx.prisma.$transaction( const BATCH_SIZE = 25
results.map((r) => for (let i = 0; i < results.length; i += BATCH_SIZE) {
ctx.prisma.filteringResult.upsert({ const batch = results.slice(i, i + BATCH_SIZE)
where: { await ctx.prisma.$transaction(
roundId_projectId: { batch.map((r) =>
ctx.prisma.filteringResult.upsert({
where: {
roundId_projectId: {
roundId: input.roundId,
projectId: r.projectId,
},
},
create: {
roundId: input.roundId, roundId: input.roundId,
projectId: r.projectId, projectId: r.projectId,
outcome: r.outcome,
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
}, },
}, update: {
create: { outcome: r.outcome,
roundId: input.roundId, ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue,
projectId: r.projectId, aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue,
outcome: r.outcome, // Clear any previous override
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue, overriddenBy: null,
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue, overriddenAt: null,
}, overrideReason: null,
update: { finalOutcome: null,
outcome: r.outcome, },
ruleResultsJson: r.ruleResults as unknown as Prisma.InputJsonValue, })
aiScreeningJson: (r.aiScreeningJson ?? Prisma.JsonNull) as Prisma.InputJsonValue, )
// Clear any previous override
overriddenBy: null,
overriddenAt: null,
overrideReason: null,
finalOutcome: null,
},
})
) )
) }
await logAudit({ await logAudit({
userId: ctx.user.id, userId: ctx.user.id,

View File

@ -1,5 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
import { router, adminProcedure } from '../trpc' import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const gracePeriodRouter = router({ export const gracePeriodRouter = router({
/** /**
@ -24,21 +25,20 @@ export const gracePeriodRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'GRANT_GRACE_PERIOD', action: 'GRANT_GRACE_PERIOD',
entityType: 'GracePeriod', entityType: 'GracePeriod',
entityId: gracePeriod.id, entityId: gracePeriod.id,
detailsJson: { detailsJson: {
roundId: input.roundId, roundId: input.roundId,
userId: input.userId, userId: input.userId,
projectId: input.projectId, projectId: input.projectId,
extendedUntil: input.extendedUntil.toISOString(), extendedUntil: input.extendedUntil.toISOString(),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return gracePeriod return gracePeriod
@ -119,16 +119,15 @@ export const gracePeriodRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE_GRACE_PERIOD', action: 'UPDATE_GRACE_PERIOD',
entityType: 'GracePeriod', entityType: 'GracePeriod',
entityId: id, entityId: id,
detailsJson: data, detailsJson: data,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return gracePeriod return gracePeriod
@ -145,19 +144,18 @@ export const gracePeriodRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'REVOKE_GRACE_PERIOD', action: 'REVOKE_GRACE_PERIOD',
entityType: 'GracePeriod', entityType: 'GracePeriod',
entityId: input.id, entityId: input.id,
detailsJson: { detailsJson: {
userId: gracePeriod.userId, userId: gracePeriod.userId,
roundId: gracePeriod.roundId, roundId: gracePeriod.roundId,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return gracePeriod return gracePeriod
@ -188,19 +186,18 @@ export const gracePeriodRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'BULK_GRANT_GRACE_PERIOD', action: 'BULK_GRANT_GRACE_PERIOD',
entityType: 'GracePeriod', entityType: 'GracePeriod',
detailsJson: { detailsJson: {
roundId: input.roundId, roundId: input.roundId,
userCount: input.userIds.length, userCount: input.userIds.length,
created: created.count, created: created.count,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return { created: created.count } return { created: created.count }

View File

@ -6,6 +6,7 @@ import {
adminProcedure, adminProcedure,
} from '../trpc' } from '../trpc'
import { getPresignedUrl } from '@/lib/minio' import { getPresignedUrl } from '@/lib/minio'
import { logAudit } from '../utils/audit'
// Bucket for learning resources // Bucket for learning resources
export const LEARNING_BUCKET = 'mopc-learning' export const LEARNING_BUCKET = 'mopc-learning'
@ -312,16 +313,15 @@ export const learningResourceRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'CREATE', action: 'CREATE',
entityType: 'LearningResource', entityType: 'LearningResource',
entityId: resource.id, entityId: resource.id,
detailsJson: { title: input.title, resourceType: input.resourceType }, detailsJson: { title: input.title, resourceType: input.resourceType },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return resource return resource
@ -359,16 +359,15 @@ export const learningResourceRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE', action: 'UPDATE',
entityType: 'LearningResource', entityType: 'LearningResource',
entityId: id, entityId: id,
detailsJson: data, detailsJson: data,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return resource return resource
@ -385,16 +384,15 @@ export const learningResourceRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'DELETE', action: 'DELETE',
entityType: 'LearningResource', entityType: 'LearningResource',
entityId: input.id, entityId: input.id,
detailsJson: { title: resource.title }, detailsJson: { title: resource.title },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return resource return resource
@ -480,15 +478,14 @@ export const learningResourceRouter = router({
) )
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'REORDER', action: 'REORDER',
entityType: 'LearningResource', entityType: 'LearningResource',
detailsJson: { count: input.items.length }, detailsJson: { count: input.items.length },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { success: true } return { success: true }

View File

@ -1,6 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const liveVotingRouter = router({ export const liveVotingRouter = router({
/** /**
@ -227,16 +228,15 @@ export const liveVotingRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'START_VOTING', action: 'START_VOTING',
entityType: 'LiveVotingSession', entityType: 'LiveVotingSession',
entityId: session.id, entityId: session.id,
detailsJson: { projectId: input.projectId, durationSeconds: input.durationSeconds }, detailsJson: { projectId: input.projectId, durationSeconds: input.durationSeconds },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return session return session
@ -273,16 +273,15 @@ export const liveVotingRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'END_SESSION', action: 'END_SESSION',
entityType: 'LiveVotingSession', entityType: 'LiveVotingSession',
entityId: session.id, entityId: session.id,
detailsJson: {}, detailsJson: {},
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return session return session

View File

@ -1,14 +1,44 @@
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc' import { router, adminProcedure } from '../trpc'
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
import { import {
getStorageProviderWithType, getImageUploadUrl,
createStorageProvider, confirmImageUpload,
generateLogoKey, getImageUrl,
getContentType, deleteImage,
isValidImageType, type ImageUploadConfig,
type StorageProviderType, } from '../utils/image-upload'
} from '@/lib/storage'
type LogoSelect = {
logoKey: string | null
logoProvider: string | null
}
const logoConfig: ImageUploadConfig<LogoSelect> = {
label: 'logo',
generateKey: generateLogoKey,
findCurrent: (prisma, entityId) =>
prisma.project.findUnique({
where: { id: entityId },
select: { logoKey: true, logoProvider: true },
}),
getImageKey: (record) => record.logoKey,
getProviderType: (record) =>
(record.logoProvider as StorageProviderType) || 's3',
setImage: (prisma, entityId, key, providerType) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: key, logoProvider: providerType },
}),
clearImage: (prisma, entityId) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: null, logoProvider: null },
}),
auditEntityType: 'Project',
auditFieldName: 'logoKey',
}
export const logoRouter = router({ export const logoRouter = router({
/** /**
@ -23,11 +53,6 @@ export const logoRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Validate content type
if (!isValidImageType(input.contentType)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP' })
}
// Verify project exists // Verify project exists
const project = await ctx.prisma.project.findUnique({ const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId }, where: { id: input.projectId },
@ -38,17 +63,12 @@ export const logoRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
} }
const key = generateLogoKey(input.projectId, input.fileName) return getImageUploadUrl(
const contentType = getContentType(input.fileName) input.projectId,
input.fileName,
const { provider, providerType } = await getStorageProviderWithType() input.contentType,
const uploadUrl = await provider.getUploadUrl(key, contentType) generateLogoKey
)
return {
uploadUrl,
key,
providerType, // Return so client can pass it back on confirm
}
}), }),
/** /**
@ -63,38 +83,22 @@ export const logoRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Use the provider that was used for upload await confirmImageUpload(
const provider = createStorageProvider(input.providerType) ctx.prisma,
const exists = await provider.objectExists(input.key) logoConfig,
if (!exists) { input.projectId,
throw new TRPCError({ code: 'NOT_FOUND', message: 'Upload not found. Please try uploading again.' }) input.key,
} input.providerType,
{
// Delete old logo if exists (from its original provider) userId: ctx.user.id,
const currentProject = await ctx.prisma.project.findUnique({ ip: ctx.ip,
where: { id: input.projectId }, userAgent: ctx.userAgent,
select: { logoKey: true, logoProvider: true },
})
if (currentProject?.logoKey) {
try {
const oldProvider = createStorageProvider(
(currentProject.logoProvider as StorageProviderType) || 's3'
)
await oldProvider.deleteObject(currentProject.logoKey)
} catch (error) {
// Log but don't fail if old logo deletion fails
console.warn('Failed to delete old logo:', error)
} }
} )
// Update project with new logo key and provider // Return the updated project fields to match original API contract
const project = await ctx.prisma.project.update({ const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId }, where: { id: input.projectId },
data: {
logoKey: input.key,
logoProvider: input.providerType,
},
select: { select: {
id: true, id: true,
logoKey: true, logoKey: true,
@ -102,23 +106,6 @@ export const logoRouter = router({
}, },
}) })
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Project',
entityId: input.projectId,
detailsJson: {
field: 'logoKey',
newValue: input.key,
provider: input.providerType,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project return project
}), }),
@ -128,21 +115,7 @@ export const logoRouter = router({
getUrl: adminProcedure getUrl: adminProcedure
.input(z.object({ projectId: z.string() })) .input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUnique({ return getImageUrl(ctx.prisma, logoConfig, input.projectId)
where: { id: input.projectId },
select: { logoKey: true, logoProvider: true },
})
if (!project?.logoKey) {
return null
}
// Use the provider that was used when the file was stored
const providerType = (project.logoProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
const url = await provider.getDownloadUrl(project.logoKey)
return url
}), }),
/** /**
@ -151,46 +124,10 @@ export const logoRouter = router({
delete: adminProcedure delete: adminProcedure
.input(z.object({ projectId: z.string() })) .input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUnique({ return deleteImage(ctx.prisma, logoConfig, input.projectId, {
where: { id: input.projectId }, userId: ctx.user.id,
select: { logoKey: true, logoProvider: true }, ip: ctx.ip,
userAgent: ctx.userAgent,
}) })
if (!project?.logoKey) {
return { success: true }
}
// Delete from the provider that was used when the file was stored
const providerType = (project.logoProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
try {
await provider.deleteObject(project.logoKey)
} catch (error) {
console.warn('Failed to delete logo from storage:', error)
}
// Update project - clear both key and provider
await ctx.prisma.project.update({
where: { id: input.projectId },
data: {
logoKey: null,
logoProvider: null,
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Project',
entityId: input.projectId,
detailsJson: { field: 'logoKey' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}), }),
}) })

View File

@ -11,6 +11,7 @@ import {
notifyProjectTeam, notifyProjectTeam,
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
export const mentorRouter = router({ export const mentorRouter = router({
/** /**
@ -46,36 +47,37 @@ export const mentorRouter = router({
input.limit input.limit
) )
// Enrich with mentor details // Enrich with mentor details (batch query to avoid N+1)
const enrichedSuggestions = await Promise.all( const mentorIds = suggestions.map((s) => s.mentorId)
suggestions.map(async (suggestion) => { const mentors = await ctx.prisma.user.findMany({
const mentor = await ctx.prisma.user.findUnique({ where: { id: { in: mentorIds } },
where: { id: suggestion.mentorId }, select: {
select: { id: true,
id: true, name: true,
name: true, email: true,
email: true, expertiseTags: true,
expertiseTags: true, mentorAssignments: {
mentorAssignments: { select: { id: true },
select: { id: true }, },
}, },
}, })
}) const mentorMap = new Map(mentors.map((m) => [m.id, m]))
return { const enrichedSuggestions = suggestions.map((suggestion) => {
...suggestion, const mentor = mentorMap.get(suggestion.mentorId)
mentor: mentor return {
? { ...suggestion,
id: mentor.id, mentor: mentor
name: mentor.name, ? {
email: mentor.email, id: mentor.id,
expertiseTags: mentor.expertiseTags, name: mentor.name,
assignmentCount: mentor.mentorAssignments.length, email: mentor.email,
} expertiseTags: mentor.expertiseTags,
: null, assignmentCount: mentor.mentorAssignments.length,
} }
}) : null,
) }
})
return { return {
currentMentor: null, currentMentor: null,
@ -117,52 +119,54 @@ export const mentorRouter = router({
where: { id: input.mentorId }, where: { id: input.mentorId },
}) })
// Create assignment // Create assignment + audit log in transaction
const assignment = await ctx.prisma.mentorAssignment.create({ const assignment = await ctx.prisma.$transaction(async (tx) => {
data: { const created = await tx.mentorAssignment.create({
projectId: input.projectId, data: {
mentorId: input.mentorId, projectId: input.projectId,
method: input.method, mentorId: input.mentorId,
assignedBy: ctx.user.id, method: input.method,
aiConfidenceScore: input.aiConfidenceScore, assignedBy: ctx.user.id,
expertiseMatchScore: input.expertiseMatchScore, aiConfidenceScore: input.aiConfidenceScore,
aiReasoning: input.aiReasoning, expertiseMatchScore: input.expertiseMatchScore,
}, aiReasoning: input.aiReasoning,
include: { },
mentor: { include: {
select: { mentor: {
id: true, select: {
name: true, id: true,
email: true, name: true,
expertiseTags: true, email: true,
expertiseTags: true,
},
},
project: {
select: {
id: true,
title: true,
},
}, },
}, },
project: { })
select: {
id: true,
title: true,
},
},
},
})
// Create audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'MENTOR_ASSIGN', action: 'MENTOR_ASSIGN',
entityType: 'MentorAssignment', entityType: 'MentorAssignment',
entityId: assignment.id, entityId: created.id,
detailsJson: { detailsJson: {
projectId: input.projectId, projectId: input.projectId,
projectTitle: assignment.project.title, projectTitle: created.project.title,
mentorId: input.mentorId, mentorId: input.mentorId,
mentorName: assignment.mentor.name, mentorName: created.mentor.name,
method: input.method, method: input.method,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return created
}) })
// Get team lead info for mentor notification // Get team lead info for mentor notification
@ -291,23 +295,22 @@ export const mentorRouter = router({
}) })
// Create audit log // Create audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'MENTOR_AUTO_ASSIGN', action: 'MENTOR_AUTO_ASSIGN',
entityType: 'MentorAssignment', entityType: 'MentorAssignment',
entityId: assignment.id, entityId: assignment.id,
detailsJson: { detailsJson: {
projectId: input.projectId, projectId: input.projectId,
projectTitle: assignment.project.title, projectTitle: assignment.project.title,
mentorId, mentorId,
mentorName: assignment.mentor.name, mentorName: assignment.mentor.name,
method, method,
aiConfidenceScore, aiConfidenceScore,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
// Get team lead info for mentor notification // Get team lead info for mentor notification
@ -370,13 +373,10 @@ export const mentorRouter = router({
}) })
} }
await ctx.prisma.mentorAssignment.delete({ // Delete assignment + audit log in transaction
where: { projectId: input.projectId }, await ctx.prisma.$transaction(async (tx) => {
}) await logAudit({
prisma: tx,
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'MENTOR_UNASSIGN', action: 'MENTOR_UNASSIGN',
entityType: 'MentorAssignment', entityType: 'MentorAssignment',
@ -389,7 +389,11 @@ export const mentorRouter = router({
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
await tx.mentorAssignment.delete({
where: { projectId: input.projectId },
})
}) })
return { success: true } return { success: true }
@ -517,20 +521,19 @@ export const mentorRouter = router({
} }
// Create audit log // Create audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN', action: 'MENTOR_BULK_ASSIGN',
entityType: 'Round', entityType: 'Round',
entityId: input.roundId, entityId: input.roundId,
detailsJson: { detailsJson: {
assigned, assigned,
failed, failed,
useAI: input.useAI, useAI: input.useAI,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return { return {
@ -632,6 +635,109 @@ export const mentorRouter = router({
} }
}), }),
/**
* Send a message to the project team (mentor side)
*/
sendMessage: mentorProcedure
.input(
z.object({
projectId: z.string(),
message: z.string().min(1).max(5000),
})
)
.mutation(async ({ ctx, input }) => {
// Verify the mentor is assigned to this project
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: {
projectId: input.projectId,
mentorId: ctx.user.id,
},
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!assignment && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to mentor this project',
})
}
const mentorMessage = await ctx.prisma.mentorMessage.create({
data: {
projectId: input.projectId,
senderId: ctx.user.id,
message: input.message,
},
include: {
sender: {
select: { id: true, name: true, email: true },
},
},
})
// Notify project team members
await notifyProjectTeam(input.projectId, {
type: 'MENTOR_MESSAGE',
title: 'New Message from Mentor',
message: `${ctx.user.name || 'Your mentor'} sent you a message`,
linkUrl: `/my-submission/${input.projectId}`,
linkLabel: 'View Message',
priority: 'normal',
metadata: {
projectId: input.projectId,
},
})
return mentorMessage
}),
/**
* Get messages for a project (mentor side)
*/
getMessages: mentorProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
// Verify the mentor is assigned to this project
const assignment = await ctx.prisma.mentorAssignment.findFirst({
where: {
projectId: input.projectId,
mentorId: ctx.user.id,
},
})
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!assignment && !isAdmin) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You are not assigned to mentor this project',
})
}
const messages = await ctx.prisma.mentorMessage.findMany({
where: { projectId: input.projectId },
include: {
sender: {
select: { id: true, name: true, email: true, role: true },
},
},
orderBy: { createdAt: 'asc' },
})
// Mark unread messages from the team as read
await ctx.prisma.mentorMessage.updateMany({
where: {
projectId: input.projectId,
senderId: { not: ctx.user.id },
isRead: false,
},
data: { isRead: true },
})
return messages
}),
/** /**
* List all mentor assignments (admin) * List all mentor assignments (admin)
*/ */

View File

@ -1,6 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio' import { getPresignedUrl } from '@/lib/minio'
import { logAudit } from '../utils/audit'
// Bucket for partner logos // Bucket for partner logos
export const PARTNER_BUCKET = 'mopc-partners' export const PARTNER_BUCKET = 'mopc-partners'
@ -174,16 +175,15 @@ export const partnerRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'CREATE', action: 'CREATE',
entityType: 'Partner', entityType: 'Partner',
entityId: partner.id, entityId: partner.id,
detailsJson: { name: input.name, partnerType: input.partnerType }, detailsJson: { name: input.name, partnerType: input.partnerType },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return partner return partner
@ -218,16 +218,15 @@ export const partnerRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE', action: 'UPDATE',
entityType: 'Partner', entityType: 'Partner',
entityId: id, entityId: id,
detailsJson: data, detailsJson: data,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return partner return partner
@ -244,16 +243,15 @@ export const partnerRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'DELETE', action: 'DELETE',
entityType: 'Partner', entityType: 'Partner',
entityId: input.id, entityId: input.id,
detailsJson: { name: partner.name }, detailsJson: { name: partner.name },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return partner return partner
@ -308,15 +306,14 @@ export const partnerRouter = router({
) )
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'REORDER', action: 'REORDER',
entityType: 'Partner', entityType: 'Partner',
detailsJson: { count: input.items.length }, detailsJson: { count: input.items.length },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { success: true } return { success: true }
@ -339,15 +336,14 @@ export const partnerRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'BULK_UPDATE', action: 'BULK_UPDATE',
entityType: 'Partner', entityType: 'Partner',
detailsJson: { ids: input.ids, visibility: input.visibility }, detailsJson: { ids: input.ids, visibility: input.visibility },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { updated: input.ids.length } return { updated: input.ids.length }

View File

@ -1,5 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
import { router, protectedProcedure, adminProcedure } from '../trpc' import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const programRouter = router({ export const programRouter = router({
/** /**
@ -70,16 +71,15 @@ export const programRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'CREATE', action: 'CREATE',
entityType: 'Program', entityType: 'Program',
entityId: program.id, entityId: program.id,
detailsJson: input, detailsJson: input,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return program return program
@ -106,16 +106,15 @@ export const programRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE', action: 'UPDATE',
entityType: 'Program', entityType: 'Program',
entityId: id, entityId: id,
detailsJson: data, detailsJson: data,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return program return program
@ -133,16 +132,15 @@ export const programRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'DELETE', action: 'DELETE',
entityType: 'Program', entityType: 'Program',
entityId: input.id, entityId: input.id,
detailsJson: { name: program.name, year: program.year }, detailsJson: { name: program.name, year: program.year },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return program return program

View File

@ -8,6 +8,7 @@ import {
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'
export const projectRouter = router({ export const projectRouter = router({
/** /**
@ -55,7 +56,7 @@ export const projectRouter = router({
hasFiles: z.boolean().optional(), hasFiles: z.boolean().optional(),
hasAssignments: z.boolean().optional(), hasAssignments: z.boolean().optional(),
page: z.number().int().min(1).default(1), page: z.number().int().min(1).default(1),
perPage: z.number().int().min(1).max(5000).default(20), perPage: z.number().int().min(1).max(200).default(20),
}) })
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@ -131,7 +132,6 @@ export const projectRouter = router({
take: perPage, take: perPage,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
files: true,
round: { round: {
select: { select: {
id: true, id: true,
@ -139,7 +139,7 @@ export const projectRouter = router({
program: { select: { id: true, name: true, year: true } }, program: { select: { id: true, name: true, year: true } },
}, },
}, },
_count: { select: { assignments: true } }, _count: { select: { assignments: true, files: true } },
}, },
}), }),
ctx.prisma.project.count({ where }), ctx.prisma.project.count({ where }),
@ -298,25 +298,27 @@ export const projectRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { metadataJson, ...rest } = input const { metadataJson, ...rest } = input
const project = await ctx.prisma.project.create({ const project = await ctx.prisma.$transaction(async (tx) => {
data: { const created = await tx.project.create({
...rest, data: {
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, ...rest,
status: 'SUBMITTED', metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}, status: 'SUBMITTED',
}) },
})
// Audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'CREATE', action: 'CREATE',
entityType: 'Project', entityType: 'Project',
entityId: project.id, entityId: created.id,
detailsJson: { title: input.title, roundId: input.roundId }, detailsJson: { title: input.title, roundId: input.roundId },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return created
}) })
return project return project
@ -368,6 +370,17 @@ export const projectRouter = router({
}, },
}) })
// Record status change in history
if (status) {
await ctx.prisma.projectStatusHistory.create({
data: {
projectId: id,
status,
changedBy: ctx.user.id,
},
})
}
// Send notifications if status changed // Send notifications if status changed
if (status) { if (status) {
// Get round details for notification // Get round details for notification
@ -458,16 +471,15 @@ export const projectRouter = router({
} }
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE', action: 'UPDATE',
entityType: 'Project', entityType: 'Project',
entityId: id, entityId: id,
detailsJson: { ...data, status, metadataJson } as Prisma.InputJsonValue, detailsJson: { ...data, status, metadataJson } as Record<string, unknown>,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return project return project
@ -479,21 +491,26 @@ export const projectRouter = router({
delete: adminProcedure delete: adminProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.delete({ const project = await ctx.prisma.$transaction(async (tx) => {
where: { id: input.id }, const target = await tx.project.findUniqueOrThrow({
}) where: { id: input.id },
select: { id: true, title: true },
})
// Audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'DELETE', action: 'DELETE',
entityType: 'Project', entityType: 'Project',
entityId: input.id, entityId: input.id,
detailsJson: { title: project.title }, detailsJson: { title: target.title },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return tx.project.delete({
where: { id: input.id },
})
}) })
return project return project
@ -560,15 +577,14 @@ export const projectRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'IMPORT', action: 'IMPORT',
entityType: 'Project', entityType: 'Project',
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported }, detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return result return result
@ -618,40 +634,53 @@ export const projectRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const updated = await ctx.prisma.project.updateMany({ // Fetch matching projects BEFORE update so notifications match actually-updated records
where: {
id: { in: input.ids },
roundId: input.roundId,
},
data: { status: input.status },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: input.ids, roundId: input.roundId, status: input.status },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Get round details including configured notification type
const [projects, round] = await Promise.all([ const [projects, round] = await Promise.all([
input.ids.length > 0 ctx.prisma.project.findMany({
? ctx.prisma.project.findMany({ where: {
where: { id: { in: input.ids } }, id: { in: input.ids },
select: { id: true, title: true }, roundId: input.roundId,
}) },
: Promise.resolve([]), select: { id: true, title: true },
}),
ctx.prisma.round.findUnique({ ctx.prisma.round.findUnique({
where: { id: input.roundId }, where: { id: input.roundId },
select: { name: true, entryNotificationType: true, program: { select: { name: true } } }, select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
}), }),
]) ])
const matchingIds = projects.map((p) => p.id)
const updated = await ctx.prisma.project.updateMany({
where: {
id: { in: matchingIds },
roundId: input.roundId,
},
data: { status: input.status },
})
// Record status change in history for each project
if (matchingIds.length > 0) {
await ctx.prisma.projectStatusHistory.createMany({
data: matchingIds.map((projectId) => ({
projectId,
status: input.status,
changedBy: ctx.user.id,
})),
})
}
// Audit log
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: matchingIds, roundId: input.roundId, status: input.status, count: updated.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Helper to get notification title based on type // Helper to get notification title based on type
const getNotificationTitle = (type: string): string => { const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = { const titles: Record<string, string> = {

View File

@ -6,6 +6,7 @@ import {
notifyRoundJury, notifyRoundJury,
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
export const roundRouter = router({ export const roundRouter = router({
/** /**
@ -114,40 +115,43 @@ export const roundRouter = router({
const now = new Date() const now = new Date()
const shouldAutoActivate = input.votingStartAt && input.votingStartAt <= now const shouldAutoActivate = input.votingStartAt && input.votingStartAt <= now
const round = await ctx.prisma.round.create({ const round = await ctx.prisma.$transaction(async (tx) => {
data: { const created = await tx.round.create({
...rest,
sortOrder,
status: shouldAutoActivate ? 'ACTIVE' : 'DRAFT',
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
},
})
// For FILTERING rounds, automatically move all projects from the program to this round
if (input.roundType === 'FILTERING') {
await ctx.prisma.project.updateMany({
where: {
round: { programId: input.programId },
roundId: { not: round.id },
},
data: { data: {
roundId: round.id, ...rest,
status: 'SUBMITTED', sortOrder,
status: shouldAutoActivate ? 'ACTIVE' : 'DRAFT',
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
}, },
}) })
}
// Audit log // For FILTERING rounds, automatically move all projects from the program to this round
await ctx.prisma.auditLog.create({ if (input.roundType === 'FILTERING') {
data: { await tx.project.updateMany({
where: {
round: { programId: input.programId },
roundId: { not: created.id },
},
data: {
roundId: created.id,
status: 'SUBMITTED',
},
})
}
// Audit log
await logAudit({
prisma: tx,
userId: ctx.user.id, userId: ctx.user.id,
action: 'CREATE', action: 'CREATE',
entityType: 'Round', entityType: 'Round',
entityId: round.id, entityId: created.id,
detailsJson: { ...rest, settingsJson } as Prisma.InputJsonValue, detailsJson: { ...rest, settingsJson } as Record<string, unknown>,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return created
}) })
return round return round
@ -215,26 +219,28 @@ export const roundRouter = router({
} }
} }
const round = await ctx.prisma.round.update({ const round = await ctx.prisma.$transaction(async (tx) => {
where: { id }, const updated = await tx.round.update({
data: { where: { id },
...data, data: {
...(autoActivate && { status: 'ACTIVE' }), ...data,
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined, ...(autoActivate && { status: 'ACTIVE' }),
}, settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
}) },
})
// Audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE', action: 'UPDATE',
entityType: 'Round', entityType: 'Round',
entityId: id, entityId: id,
detailsJson: { ...data, settingsJson } as Prisma.InputJsonValue, detailsJson: { ...data, settingsJson } as Record<string, unknown>,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return updated
}) })
return round return round
@ -275,11 +281,6 @@ export const roundRouter = router({
} }
} }
const round = await ctx.prisma.round.update({
where: { id: input.id },
data: updateData,
})
// Map status to specific action name // Map status to specific action name
const statusActionMap: Record<string, string> = { const statusActionMap: Record<string, string> = {
ACTIVE: 'ROUND_ACTIVATED', ACTIVE: 'ROUND_ACTIVATED',
@ -288,9 +289,14 @@ export const roundRouter = router({
} }
const action = statusActionMap[input.status] || 'UPDATE_STATUS' const action = statusActionMap[input.status] || 'UPDATE_STATUS'
// Audit log const round = await ctx.prisma.$transaction(async (tx) => {
await ctx.prisma.auditLog.create({ const updated = await tx.round.update({
data: { where: { id: input.id },
data: updateData,
})
await logAudit({
prisma: tx,
userId: ctx.user.id, userId: ctx.user.id,
action, action,
entityType: 'Round', entityType: 'Round',
@ -306,7 +312,9 @@ export const roundRouter = router({
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return updated
}) })
// Notify jury members when round is activated // Notify jury members when round is activated
@ -435,9 +443,25 @@ export const roundRouter = router({
id: z.string(), id: z.string(),
label: z.string().min(1), label: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
scale: z.number().int().min(1).max(10), type: z.enum(['numeric', 'text', 'boolean', 'section_header']).default('numeric'),
// Numeric fields
scale: z.number().int().min(1).max(10).optional(),
weight: z.number().optional(), weight: z.number().optional(),
required: z.boolean(), required: z.boolean().optional(),
// Text fields
maxLength: z.number().int().min(1).max(10000).optional(),
placeholder: z.string().optional(),
// Boolean fields
trueLabel: z.string().optional(),
falseLabel: z.string().optional(),
// Conditional visibility
condition: z.object({
criterionId: z.string(),
operator: z.enum(['equals', 'greaterThan', 'lessThan']),
value: z.union([z.number(), z.string(), z.boolean()]),
}).optional(),
// Section grouping
sectionId: z.string().optional(),
}) })
), ),
}) })
@ -485,16 +509,15 @@ export const roundRouter = router({
} }
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE_EVALUATION_FORM', action: 'UPDATE_EVALUATION_FORM',
entityType: 'EvaluationForm', entityType: 'EvaluationForm',
entityId: form.id, entityId: form.id,
detailsJson: { roundId, criteriaCount: criteria.length }, detailsJson: { roundId, criteriaCount: criteria.length },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return form return form
@ -525,13 +548,9 @@ export const roundRouter = router({
}, },
}) })
await ctx.prisma.round.delete({ await ctx.prisma.$transaction(async (tx) => {
where: { id: input.id }, await logAudit({
}) prisma: tx,
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'DELETE', action: 'DELETE',
entityType: 'Round', entityType: 'Round',
@ -544,7 +563,11 @@ export const roundRouter = router({
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
await tx.round.delete({
where: { id: input.id },
})
}) })
return round return round
@ -601,16 +624,15 @@ export const roundRouter = router({
} }
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'ASSIGN_PROJECTS_TO_ROUND', action: 'ASSIGN_PROJECTS_TO_ROUND',
entityType: 'Round', entityType: 'Round',
entityId: input.roundId, entityId: input.roundId,
detailsJson: { projectCount: updated.count }, detailsJson: { projectCount: updated.count },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { assigned: updated.count } return { assigned: updated.count }
@ -640,16 +662,15 @@ export const roundRouter = router({
const deleted = { count: updated.count } const deleted = { count: updated.count }
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'REMOVE_PROJECTS_FROM_ROUND', action: 'REMOVE_PROJECTS_FROM_ROUND',
entityType: 'Round', entityType: 'Round',
entityId: input.roundId, entityId: input.roundId,
detailsJson: { projectCount: deleted.count }, detailsJson: { projectCount: deleted.count },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { removed: deleted.count } return { removed: deleted.count }
@ -711,20 +732,19 @@ export const roundRouter = router({
const created = { count: updated.count } const created = { count: updated.count }
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'ADVANCE_PROJECTS', action: 'ADVANCE_PROJECTS',
entityType: 'Round', entityType: 'Round',
entityId: input.toRoundId, entityId: input.toRoundId,
detailsJson: { detailsJson: {
fromRoundId: input.fromRoundId, fromRoundId: input.fromRoundId,
toRoundId: input.toRoundId, toRoundId: input.toRoundId,
projectCount: created.count, projectCount: created.count,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return { advanced: created.count } return { advanced: created.count }
@ -752,16 +772,15 @@ export const roundRouter = router({
) )
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'REORDER_ROUNDS', action: 'REORDER_ROUNDS',
entityType: 'Program', entityType: 'Program',
entityId: input.programId, entityId: input.programId,
detailsJson: { roundIds: input.roundIds }, detailsJson: { roundIds: input.roundIds },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { success: true } return { success: true }

View File

@ -3,6 +3,8 @@ import { router, adminProcedure, superAdminProcedure, protectedProcedure } from
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp' import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
import { listAvailableModels, testOpenAIConnection, isReasoningModel } from '@/lib/openai' import { listAvailableModels, testOpenAIConnection, isReasoningModel } from '@/lib/openai'
import { getAIUsageStats, getCurrentMonthCost, formatCost } from '@/server/utils/ai-usage' import { getAIUsageStats, getCurrentMonthCost, formatCost } from '@/server/utils/ai-usage'
import { clearStorageProviderCache } from '@/lib/storage'
import { logAudit } from '../utils/audit'
/** /**
* Categorize an OpenAI model for display * Categorize an OpenAI model for display
@ -117,20 +119,24 @@ export const settingsRouter = router({
}, },
}) })
// Clear storage provider cache when storage_provider setting changes
if (input.key === 'storage_provider') {
clearStorageProviderCache()
}
// Audit log (don't log actual value for secrets) // Audit log (don't log actual value for secrets)
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE_SETTING', action: 'UPDATE_SETTING',
entityType: 'SystemSettings', entityType: 'SystemSettings',
entityId: setting.id, entityId: setting.id,
detailsJson: { detailsJson: {
key: input.key, key: input.key,
isSecret: setting.isSecret, isSecret: setting.isSecret,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return setting return setting
@ -181,16 +187,20 @@ export const settingsRouter = router({
) )
) )
// Clear storage provider cache if storage_provider was updated
if (input.settings.some((s) => s.key === 'storage_provider')) {
clearStorageProviderCache()
}
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE_SETTINGS_BATCH', action: 'UPDATE_SETTINGS_BATCH',
entityType: 'SystemSettings', entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) }, detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return results return results
@ -346,19 +356,18 @@ export const settingsRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE_NOTIFICATION_PREFERENCES', action: 'UPDATE_NOTIFICATION_PREFERENCES',
entityType: 'User', entityType: 'User',
entityId: ctx.user.id, entityId: ctx.user.id,
detailsJson: { detailsJson: {
notificationPreference: input.notificationPreference, notificationPreference: input.notificationPreference,
whatsappOptIn: input.whatsappOptIn, whatsappOptIn: input.whatsappOptIn,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return user return user

View File

@ -97,26 +97,32 @@ export const specialAwardRouter = router({
_max: { sortOrder: true }, _max: { sortOrder: true },
}) })
const award = await ctx.prisma.specialAward.create({ const award = await ctx.prisma.$transaction(async (tx) => {
data: { const created = await tx.specialAward.create({
programId: input.programId, data: {
name: input.name, programId: input.programId,
description: input.description, name: input.name,
criteriaText: input.criteriaText, description: input.description,
useAiEligibility: input.useAiEligibility ?? true, criteriaText: input.criteriaText,
scoringMode: input.scoringMode, useAiEligibility: input.useAiEligibility ?? true,
maxRankedPicks: input.maxRankedPicks, scoringMode: input.scoringMode,
autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined, maxRankedPicks: input.maxRankedPicks,
sortOrder: (maxOrder._max.sortOrder || 0) + 1, autoTagRulesJson: input.autoTagRulesJson as Prisma.InputJsonValue ?? undefined,
}, sortOrder: (maxOrder._max.sortOrder || 0) + 1,
}) },
})
await logAudit({ await tx.auditLog.create({
userId: ctx.user.id, data: {
action: 'CREATE', userId: ctx.user.id,
entityType: 'SpecialAward', action: 'CREATE',
entityId: award.id, entityType: 'SpecialAward',
detailsJson: { name: input.name, scoringMode: input.scoringMode }, entityId: created.id,
detailsJson: { name: input.name, scoringMode: input.scoringMode } as Prisma.InputJsonValue,
},
})
return created
}) })
return award return award
@ -166,13 +172,17 @@ export const specialAwardRouter = router({
delete: adminProcedure delete: adminProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
await ctx.prisma.specialAward.delete({ where: { id: input.id } }) await ctx.prisma.$transaction(async (tx) => {
await tx.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'SpecialAward',
entityId: input.id,
},
})
await logAudit({ await tx.specialAward.delete({ where: { id: input.id } })
userId: ctx.user.id,
action: 'DELETE',
entityType: 'SpecialAward',
entityId: input.id,
}) })
}), }),
@ -216,25 +226,31 @@ export const specialAwardRouter = router({
} }
} }
const award = await ctx.prisma.specialAward.update({ const award = await ctx.prisma.$transaction(async (tx) => {
where: { id: input.id }, const updated = await tx.specialAward.update({
data: updateData, where: { id: input.id },
}) data: updateData,
})
await logAudit({ await tx.auditLog.create({
userId: ctx.user.id, data: {
action: 'UPDATE_STATUS', userId: ctx.user.id,
entityType: 'SpecialAward', action: 'UPDATE_STATUS',
entityId: input.id, entityType: 'SpecialAward',
detailsJson: { entityId: input.id,
previousStatus: current.status, detailsJson: {
newStatus: input.status, previousStatus: current.status,
...(votingStartAtUpdated && { newStatus: input.status,
votingStartAtUpdated: true, ...(votingStartAtUpdated && {
previousVotingStartAt: current.votingStartAt, votingStartAtUpdated: true,
newVotingStartAt: now, previousVotingStartAt: current.votingStartAt,
}), newVotingStartAt: now,
}, }),
} as Prisma.InputJsonValue,
},
})
return updated
}) })
return award return award
@ -780,26 +796,32 @@ export const specialAwardRouter = router({
select: { winnerProjectId: true }, select: { winnerProjectId: true },
}) })
const award = await ctx.prisma.specialAward.update({ const award = await ctx.prisma.$transaction(async (tx) => {
where: { id: input.awardId }, const updated = await tx.specialAward.update({
data: { where: { id: input.awardId },
winnerProjectId: input.projectId, data: {
winnerOverridden: input.overridden, winnerProjectId: input.projectId,
winnerOverriddenBy: input.overridden ? ctx.user.id : null, winnerOverridden: input.overridden,
}, winnerOverriddenBy: input.overridden ? ctx.user.id : null,
}) },
})
await logAudit({ await tx.auditLog.create({
userId: ctx.user.id, data: {
action: 'UPDATE', userId: ctx.user.id,
entityType: 'SpecialAward', action: 'UPDATE',
entityId: input.awardId, entityType: 'SpecialAward',
detailsJson: { entityId: input.awardId,
action: 'SET_AWARD_WINNER', detailsJson: {
previousWinner: previous.winnerProjectId, action: 'SET_AWARD_WINNER',
newWinner: input.projectId, previousWinner: previous.winnerProjectId,
overridden: input.overridden, newWinner: input.projectId,
}, overridden: input.overridden,
} as Prisma.InputJsonValue,
},
})
return updated
}) })
return award return award

View File

@ -2,6 +2,7 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc' import { router, adminProcedure, protectedProcedure } from '../trpc'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { logAudit } from '../utils/audit'
import { import {
tagProject, tagProject,
getTagSuggestions, getTagSuggestions,
@ -299,16 +300,15 @@ export const tagRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'CREATE', action: 'CREATE',
entityType: 'ExpertiseTag', entityType: 'ExpertiseTag',
entityId: tag.id, entityId: tag.id,
detailsJson: { name: input.name, category: input.category }, detailsJson: { name: input.name, category: input.category },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return tag return tag
@ -399,16 +399,15 @@ export const tagRouter = router({
} }
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE', action: 'UPDATE',
entityType: 'ExpertiseTag', entityType: 'ExpertiseTag',
entityId: id, entityId: id,
detailsJson: data, detailsJson: data,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return tag return tag
@ -460,16 +459,15 @@ export const tagRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'DELETE', action: 'DELETE',
entityType: 'ExpertiseTag', entityType: 'ExpertiseTag',
entityId: input.id, entityId: input.id,
detailsJson: { name: tag.name }, detailsJson: { name: tag.name },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return tag return tag
@ -520,15 +518,14 @@ export const tagRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'BULK_CREATE', action: 'BULK_CREATE',
entityType: 'ExpertiseTag', entityType: 'ExpertiseTag',
detailsJson: { count: created.count, skipped: existingNames.size }, detailsJson: { count: created.count, skipped: existingNames.size },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { created: created.count, skipped: existingNames.size } return { created: created.count, skipped: existingNames.size }
@ -608,19 +605,18 @@ export const tagRouter = router({
const result = await tagProject(input.projectId, ctx.user.id) const result = await tagProject(input.projectId, ctx.user.id)
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'AI_TAG', action: 'AI_TAG',
entityType: 'Project', entityType: 'Project',
entityId: input.projectId, entityId: input.projectId,
detailsJson: { detailsJson: {
applied: result.applied.map((t) => t.tagName), applied: result.applied.map((t) => t.tagName),
tokensUsed: result.tokensUsed, tokensUsed: result.tokensUsed,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return result return result
@ -669,16 +665,15 @@ export const tagRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'START_AI_TAG_JOB', action: 'START_AI_TAG_JOB',
entityType: input.programId ? 'Program' : 'Round', entityType: input.programId ? 'Program' : 'Round',
entityId: input.programId || input.roundId!, entityId: input.programId || input.roundId!,
detailsJson: { jobId: job.id }, detailsJson: { jobId: job.id },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
// Start job in background (don't await) // Start job in background (don't await)
@ -760,59 +755,6 @@ export const tagRouter = router({
} }
}), }),
// Legacy endpoints kept for backward compatibility (redirect to job-based)
/**
* @deprecated Use startTaggingJob instead
*/
batchTagProjects: adminProcedure
.input(z.object({ roundId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Start job and return immediately with placeholder
const job = await ctx.prisma.taggingJob.create({
data: {
roundId: input.roundId,
status: 'PENDING',
},
})
runTaggingJob(job.id, ctx.user.id).catch(console.error)
return {
processed: 0,
failed: 0,
skipped: 0,
errors: [],
jobId: job.id,
message: 'Tagging job started in background. Check job status for progress.',
}
}),
/**
* @deprecated Use startTaggingJob instead
*/
batchTagProgramProjects: adminProcedure
.input(z.object({ programId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Start job and return immediately with placeholder
const job = await ctx.prisma.taggingJob.create({
data: {
programId: input.programId,
status: 'PENDING',
},
})
runTaggingJob(job.id, ctx.user.id).catch(console.error)
return {
processed: 0,
failed: 0,
skipped: 0,
errors: [],
jobId: job.id,
message: 'Tagging job started in background. Check job status for progress.',
}
}),
/** /**
* Manually add a tag to a project * Manually add a tag to a project
*/ */
@ -827,16 +769,15 @@ export const tagRouter = router({
await addProjectTag(input.projectId, input.tagId) await addProjectTag(input.projectId, input.tagId)
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'ADD_TAG', action: 'ADD_TAG',
entityType: 'Project', entityType: 'Project',
entityId: input.projectId, entityId: input.projectId,
detailsJson: { tagId: input.tagId }, detailsJson: { tagId: input.tagId },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { success: true } return { success: true }
@ -856,16 +797,15 @@ export const tagRouter = router({
await removeProjectTag(input.projectId, input.tagId) await removeProjectTag(input.projectId, input.tagId)
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'REMOVE_TAG', action: 'REMOVE_TAG',
entityType: 'Project', entityType: 'Project',
entityId: input.projectId, entityId: input.projectId,
detailsJson: { tagId: input.tagId }, detailsJson: { tagId: input.tagId },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { success: true } return { success: true }

View File

@ -6,6 +6,7 @@ import { router, protectedProcedure, adminProcedure, superAdminProcedure, public
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email' import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password' import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls } from '@/server/utils/avatar-url' import { attachAvatarUrls } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
@ -146,9 +147,10 @@ export const userRouter = router({
}) })
} }
// Audit log before deletion // Wrap audit + deletion in a transaction
await ctx.prisma.auditLog.create({ await ctx.prisma.$transaction(async (tx) => {
data: { await logAudit({
prisma: tx,
userId: ctx.user.id, userId: ctx.user.id,
action: 'DELETE_OWN_ACCOUNT', action: 'DELETE_OWN_ACCOUNT',
entityType: 'User', entityType: 'User',
@ -156,12 +158,11 @@ export const userRouter = router({
detailsJson: { email: user.email }, detailsJson: { email: user.email },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
})
// Delete the user await tx.user.delete({
await ctx.prisma.user.delete({ where: { id: ctx.user.id },
where: { id: ctx.user.id }, })
}) })
return { success: true } return { success: true }
@ -243,22 +244,15 @@ export const userRouter = router({
get: adminProcedure get: adminProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
console.log('[user.get] Fetching user:', input.id) const user = await ctx.prisma.user.findUniqueOrThrow({
try { where: { id: input.id },
const user = await ctx.prisma.user.findUniqueOrThrow({ include: {
where: { id: input.id }, _count: {
include: { select: { assignments: true, mentorAssignments: true },
_count: {
select: { assignments: true, mentorAssignments: true },
},
}, },
}) },
console.log('[user.get] Found user:', user.email) })
return user return user
} catch (error) {
console.error('[user.get] Error fetching user:', input.id, error)
throw error
}
}), }),
/** /**
@ -295,24 +289,26 @@ export const userRouter = router({
}) })
} }
const user = await ctx.prisma.user.create({ const user = await ctx.prisma.$transaction(async (tx) => {
data: { const created = await tx.user.create({
...input, data: {
status: 'INVITED', ...input,
}, status: 'INVITED',
}) },
})
// Audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'CREATE', action: 'CREATE',
entityType: 'User', entityType: 'User',
entityId: user.id, entityId: created.id,
detailsJson: { email: input.email, role: input.role }, detailsJson: { email: input.email, role: input.role },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return created
}) })
return user return user
@ -355,14 +351,14 @@ export const userRouter = router({
}) })
} }
const user = await ctx.prisma.user.update({ const user = await ctx.prisma.$transaction(async (tx) => {
where: { id }, const updated = await tx.user.update({
data, where: { id },
}) data,
})
// Audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE', action: 'UPDATE',
entityType: 'User', entityType: 'User',
@ -370,13 +366,12 @@ export const userRouter = router({
detailsJson: data, detailsJson: data,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
})
// Track role change specifically // Track role change specifically
if (data.role && data.role !== targetUser.role) { if (data.role && data.role !== targetUser.role) {
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: tx,
userId: ctx.user.id, userId: ctx.user.id,
action: 'ROLE_CHANGED', action: 'ROLE_CHANGED',
entityType: 'User', entityType: 'User',
@ -384,9 +379,11 @@ export const userRouter = router({
detailsJson: { previousRole: targetUser.role, newRole: data.role }, detailsJson: { previousRole: targetUser.role, newRole: data.role },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
}).catch(() => {}) }
}
return updated
})
return user return user
}), }),
@ -405,21 +402,27 @@ export const userRouter = router({
}) })
} }
const user = await ctx.prisma.user.delete({ const user = await ctx.prisma.$transaction(async (tx) => {
where: { id: input.id }, // Fetch user data before deletion for the audit log
}) const target = await tx.user.findUniqueOrThrow({
where: { id: input.id },
select: { email: true },
})
// Audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'DELETE', action: 'DELETE',
entityType: 'User', entityType: 'User',
entityId: input.id, entityId: input.id,
detailsJson: { email: user.email }, detailsJson: { email: target.email },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return tx.user.delete({
where: { id: input.id },
})
}) })
return user return user
@ -497,15 +500,14 @@ export const userRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'BULK_CREATE', action: 'BULK_CREATE',
entityType: 'User', entityType: 'User',
detailsJson: { count: created.count, skipped, duplicatesInInput }, detailsJson: { count: created.count, skipped, duplicatesInInput },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
// Auto-send invitation emails to newly created users // Auto-send invitation emails to newly created users
@ -541,15 +543,14 @@ export const userRouter = router({
// Audit log for assignments if any were created // Audit log for assignments if any were created
if (assignmentsCreated > 0) { if (assignmentsCreated > 0) {
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'BULK_ASSIGN', action: 'BULK_ASSIGN',
entityType: 'Assignment', entityType: 'Assignment',
detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' }, detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
} }
@ -699,16 +700,15 @@ export const userRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'SEND_INVITATION', action: 'SEND_INVITATION',
entityType: 'User', entityType: 'User',
entityId: user.id, entityId: user.id,
detailsJson: { email: user.email }, detailsJson: { email: user.email },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { success: true, email: user.email } return { success: true, email: user.email }
@ -777,15 +777,14 @@ export const userRouter = router({
} }
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'BULK_SEND_INVITATIONS', action: 'BULK_SEND_INVITATIONS',
entityType: 'User', entityType: 'User',
detailsJson: { sent, errors }, detailsJson: { sent, errors },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { sent, skipped: input.userIds.length - users.length, errors } return { sent, skipped: input.userIds.length - users.length, errors }
@ -817,23 +816,23 @@ export const userRouter = router({
const userTags = input.expertiseTags || [] const userTags = input.expertiseTags || []
const mergedTags = [...new Set([...adminTags, ...userTags])] const mergedTags = [...new Set([...adminTags, ...userTags])]
const user = await ctx.prisma.user.update({ const user = await ctx.prisma.$transaction(async (tx) => {
where: { id: ctx.user.id }, const updated = await tx.user.update({
data: { where: { id: ctx.user.id },
name: input.name, data: {
phoneNumber: input.phoneNumber, name: input.name,
country: input.country, phoneNumber: input.phoneNumber,
bio: input.bio, country: input.country,
expertiseTags: mergedTags, bio: input.bio,
notificationPreference: input.notificationPreference || 'EMAIL', expertiseTags: mergedTags,
onboardingCompletedAt: new Date(), notificationPreference: input.notificationPreference || 'EMAIL',
status: 'ACTIVE', // Activate user after onboarding onboardingCompletedAt: new Date(),
}, status: 'ACTIVE', // Activate user after onboarding
}) },
})
// Audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'COMPLETE_ONBOARDING', action: 'COMPLETE_ONBOARDING',
entityType: 'User', entityType: 'User',
@ -841,7 +840,9 @@ export const userRouter = router({
detailsJson: { name: input.name }, detailsJson: { name: input.name },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return updated
}) })
return user return user
@ -908,19 +909,19 @@ export const userRouter = router({
// Hash the password // Hash the password
const passwordHash = await hashPassword(input.password) const passwordHash = await hashPassword(input.password)
// Update user with new password // Update user with new password + audit in transaction
const user = await ctx.prisma.user.update({ const user = await ctx.prisma.$transaction(async (tx) => {
where: { id: ctx.user.id }, const updated = await tx.user.update({
data: { where: { id: ctx.user.id },
passwordHash, data: {
passwordSetAt: new Date(), passwordHash,
mustSetPassword: false, passwordSetAt: new Date(),
}, mustSetPassword: false,
}) },
})
// Audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'PASSWORD_SET', action: 'PASSWORD_SET',
entityType: 'User', entityType: 'User',
@ -928,7 +929,9 @@ export const userRouter = router({
detailsJson: { timestamp: new Date().toISOString() }, detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return updated
}) })
return { success: true, email: user.email } return { success: true, email: user.email }
@ -989,18 +992,18 @@ export const userRouter = router({
// Hash the new password // Hash the new password
const passwordHash = await hashPassword(input.newPassword) const passwordHash = await hashPassword(input.newPassword)
// Update user with new password // Update user with new password + audit in transaction
await ctx.prisma.user.update({ await ctx.prisma.$transaction(async (tx) => {
where: { id: ctx.user.id }, await tx.user.update({
data: { where: { id: ctx.user.id },
passwordHash, data: {
passwordSetAt: new Date(), passwordHash,
}, passwordSetAt: new Date(),
}) },
})
// Audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'PASSWORD_CHANGED', action: 'PASSWORD_CHANGED',
entityType: 'User', entityType: 'User',
@ -1008,7 +1011,7 @@ export const userRouter = router({
detailsJson: { timestamp: new Date().toISOString() }, detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
}) })
return { success: true } return { success: true }
@ -1047,16 +1050,15 @@ export const userRouter = router({
// The actual email is sent through NextAuth's email provider // The actual email is sent through NextAuth's email provider
// Audit log (without user ID since this is public) // Audit log (without user ID since this is public)
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: null, // No authenticated user userId: null, // No authenticated user
action: 'REQUEST_PASSWORD_RESET', action: 'REQUEST_PASSWORD_RESET',
entityType: 'User', entityType: 'User',
entityId: user.id, entityId: user.id,
detailsJson: { email: input.email, timestamp: new Date().toISOString() }, detailsJson: { email: input.email, timestamp: new Date().toISOString() },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' } return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }

View File

@ -17,7 +17,7 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
import { import {
anonymizeProjectsForAI, anonymizeProjectsForAI,
validateAnonymizedProjects, validateAnonymizedProjects,
type ProjectWithRelations, toProjectWithRelations,
type AnonymizedProjectForAI, type AnonymizedProjectForAI,
type ProjectAIMapping, type ProjectAIMapping,
} from './anonymization' } from './anonymization'
@ -131,32 +131,6 @@ function getFieldValue(
// ─── AI Criteria Interpretation ───────────────────────────────────────────── // ─── AI Criteria Interpretation ─────────────────────────────────────────────
/**
* Convert project to enhanced format for anonymization
*/
function toProjectWithRelations(project: ProjectForEligibility): ProjectWithRelations {
return {
id: project.id,
title: project.title,
description: project.description,
competitionCategory: project.competitionCategory as any,
oceanIssue: project.oceanIssue as any,
country: project.country,
geographicZone: project.geographicZone,
institution: project.institution,
tags: project.tags,
foundedAt: project.foundedAt,
wantsMentorship: project.wantsMentorship ?? false,
submissionSource: project.submissionSource ?? 'MANUAL',
submittedAt: project.submittedAt,
_count: {
teamMembers: project._count?.teamMembers ?? 0,
files: project._count?.files ?? 0,
},
files: project.files?.map(f => ({ fileType: f.fileType as any })) ?? [],
}
}
/** /**
* Process a batch for AI eligibility evaluation * Process a batch for AI eligibility evaluation
*/ */

View File

@ -0,0 +1,405 @@
/**
* AI-Powered Evaluation Summary Service
*
* Generates AI summaries of jury evaluations for a project in a given round.
* Combines OpenAI analysis with server-side scoring pattern calculations.
*
* GDPR Compliance:
* - All evaluation data is anonymized before AI processing
* - No juror names, emails, or identifiers are sent to OpenAI
* - Only scores, feedback text, and binary decisions are included
*/
import { TRPCError } from '@trpc/server'
import { getOpenAI, getConfiguredModel, buildCompletionParams, AI_MODELS } from '@/lib/openai'
import { logAIUsage, extractTokenUsage } from '@/server/utils/ai-usage'
import { classifyAIError, createParseError, logAIError } from './ai-errors'
import { sanitizeText } from './anonymization'
import type { PrismaClient, Prisma } from '@prisma/client'
// ─── Types ──────────────────────────────────────────────────────────────────
interface EvaluationForSummary {
id: string
criterionScoresJson: Record<string, number> | null
globalScore: number | null
binaryDecision: boolean | null
feedbackText: string | null
assignment: {
user: {
id: string
name: string | null
email: string
}
}
}
interface AnonymizedEvaluation {
criterionScores: Record<string, number> | null
globalScore: number | null
binaryDecision: boolean | null
feedbackText: string | null
}
interface CriterionDef {
id: string
label: string
}
interface AIResponsePayload {
overallAssessment: string
strengths: string[]
weaknesses: string[]
themes: Array<{
theme: string
sentiment: 'positive' | 'negative' | 'mixed'
frequency: number
}>
recommendation: string
}
interface ScoringPatterns {
averageGlobalScore: number | null
consensus: number
criterionAverages: Record<string, number>
evaluatorCount: number
}
export interface EvaluationSummaryResult {
id: string
projectId: string
roundId: string
summaryJson: AIResponsePayload & { scoringPatterns: ScoringPatterns }
generatedAt: Date
model: string
tokensUsed: number
}
// ─── Anonymization ──────────────────────────────────────────────────────────
/**
* Strip juror names/emails from evaluations, keeping only scores and feedback.
*/
export function anonymizeEvaluations(
evaluations: EvaluationForSummary[]
): AnonymizedEvaluation[] {
return evaluations.map((ev) => ({
criterionScores: ev.criterionScoresJson as Record<string, number> | null,
globalScore: ev.globalScore,
binaryDecision: ev.binaryDecision,
feedbackText: ev.feedbackText ? sanitizeText(ev.feedbackText) : null,
}))
}
// ─── Prompt Building ────────────────────────────────────────────────────────
/**
* Build the OpenAI prompt for evaluation summary generation.
*/
export function buildSummaryPrompt(
anonymizedEvaluations: AnonymizedEvaluation[],
projectTitle: string,
criteriaLabels: string[]
): string {
const sanitizedTitle = sanitizeText(projectTitle)
return `You are analyzing jury evaluations for a project competition.
PROJECT: "${sanitizedTitle}"
EVALUATION CRITERIA: ${criteriaLabels.join(', ')}
EVALUATIONS (${anonymizedEvaluations.length} total):
${JSON.stringify(anonymizedEvaluations, null, 2)}
Analyze these evaluations and return a JSON object with this exact structure:
{
"overallAssessment": "A 2-3 sentence summary of how the project was evaluated overall",
"strengths": ["strength 1", "strength 2", ...],
"weaknesses": ["weakness 1", "weakness 2", ...],
"themes": [
{ "theme": "theme name", "sentiment": "positive" | "negative" | "mixed", "frequency": <number of evaluators mentioning this> }
],
"recommendation": "A brief recommendation based on the evaluation consensus"
}
Guidelines:
- Base your analysis only on the provided evaluation data
- Identify common themes across evaluator feedback
- Note areas of agreement and disagreement
- Keep the assessment objective and balanced
- Do not include any personal identifiers`
}
// ─── Scoring Patterns (Server-Side) ─────────────────────────────────────────
/**
* Compute scoring patterns from evaluations without AI.
*/
export function computeScoringPatterns(
evaluations: EvaluationForSummary[],
criteriaLabels: CriterionDef[]
): ScoringPatterns {
const globalScores = evaluations
.map((e) => e.globalScore)
.filter((s): s is number => s !== null)
// Average global score
const averageGlobalScore =
globalScores.length > 0
? globalScores.reduce((a, b) => a + b, 0) / globalScores.length
: null
// Consensus: 1 - normalized standard deviation (1.0 = full consensus)
let consensus = 1
if (globalScores.length > 1 && averageGlobalScore !== null) {
const variance =
globalScores.reduce(
(sum, score) => sum + Math.pow(score - averageGlobalScore, 2),
0
) / globalScores.length
const stdDev = Math.sqrt(variance)
// Normalize by the scoring scale (1-10, so max possible std dev is ~4.5)
consensus = Math.max(0, 1 - stdDev / 4.5)
}
// Criterion averages
const criterionAverages: Record<string, number> = {}
for (const criterion of criteriaLabels) {
const scores: number[] = []
for (const ev of evaluations) {
const criterionScores = ev.criterionScoresJson as Record<string, number> | null
if (criterionScores && criterionScores[criterion.id] !== undefined) {
scores.push(criterionScores[criterion.id])
}
}
if (scores.length > 0) {
criterionAverages[criterion.label] =
scores.reduce((a, b) => a + b, 0) / scores.length
}
}
return {
averageGlobalScore,
consensus: Math.round(consensus * 100) / 100,
criterionAverages,
evaluatorCount: evaluations.length,
}
}
// ─── Main Orchestrator ──────────────────────────────────────────────────────
/**
* Generate an AI-powered evaluation summary for a project in a round.
*/
export async function generateSummary({
projectId,
roundId,
userId,
prisma,
}: {
projectId: string
roundId: string
userId: string
prisma: PrismaClient
}): Promise<EvaluationSummaryResult> {
// 1. Fetch project with evaluations and form criteria
const project = await prisma.project.findUnique({
where: { id: projectId },
select: {
id: true,
title: true,
roundId: true,
},
})
if (!project) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
// Fetch submitted evaluations for this project in this round
const evaluations = await prisma.evaluation.findMany({
where: {
status: 'SUBMITTED',
assignment: {
projectId,
roundId,
},
},
select: {
id: true,
criterionScoresJson: true,
globalScore: true,
binaryDecision: true,
feedbackText: true,
assignment: {
select: {
user: {
select: { id: true, name: true, email: true },
},
},
},
},
})
if (evaluations.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'No submitted evaluations found for this project in this round',
})
}
// Get evaluation form criteria for this round
const form = await prisma.evaluationForm.findFirst({
where: { roundId, isActive: true },
select: { criteriaJson: true },
})
const criteria: CriterionDef[] = form?.criteriaJson
? (form.criteriaJson as unknown as CriterionDef[])
: []
const criteriaLabels = criteria.map((c) => c.label)
// 2. Anonymize evaluations
const typedEvaluations = evaluations as unknown as EvaluationForSummary[]
const anonymized = anonymizeEvaluations(typedEvaluations)
// 3. Build prompt and call OpenAI
const openai = await getOpenAI()
if (!openai) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'OpenAI is not configured. Please set up your API key in Settings.',
})
}
const model = await getConfiguredModel(AI_MODELS.QUICK)
const prompt = buildSummaryPrompt(anonymized, project.title, criteriaLabels)
let aiResponse: AIResponsePayload
let tokensUsed = 0
try {
const params = buildCompletionParams(model, {
messages: [
{ role: 'user', content: prompt },
],
jsonMode: true,
temperature: 0.3,
maxTokens: 2000,
})
const response = await openai.chat.completions.create(params)
const usage = extractTokenUsage(response)
tokensUsed = usage.totalTokens
const content = response.choices[0]?.message?.content
if (!content) {
throw new Error('Empty response from AI')
}
aiResponse = JSON.parse(content) as AIResponsePayload
} catch (error) {
if (error instanceof SyntaxError) {
const parseError = createParseError(error.message)
logAIError('EvaluationSummary', 'generateSummary', parseError)
await logAIUsage({
userId,
action: 'EVALUATION_SUMMARY',
entityType: 'Project',
entityId: projectId,
model,
promptTokens: 0,
completionTokens: 0,
totalTokens: tokensUsed,
itemsProcessed: 0,
status: 'ERROR',
errorMessage: parseError.message,
})
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to parse AI response. Please try again.',
})
}
const classified = classifyAIError(error)
logAIError('EvaluationSummary', 'generateSummary', classified)
await logAIUsage({
userId,
action: 'EVALUATION_SUMMARY',
entityType: 'Project',
entityId: projectId,
model,
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
itemsProcessed: 0,
status: 'ERROR',
errorMessage: classified.message,
})
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: classified.message,
})
}
// 4. Compute scoring patterns (server-side, no AI)
const scoringPatterns = computeScoringPatterns(typedEvaluations, criteria)
// 5. Merge and upsert
const summaryJson = {
...aiResponse,
scoringPatterns,
}
const summaryJsonValue = summaryJson as unknown as Prisma.InputJsonValue
const summary = await prisma.evaluationSummary.upsert({
where: {
projectId_roundId: { projectId, roundId },
},
create: {
projectId,
roundId,
summaryJson: summaryJsonValue,
generatedById: userId,
model,
tokensUsed,
},
update: {
summaryJson: summaryJsonValue,
generatedAt: new Date(),
generatedById: userId,
model,
tokensUsed,
},
})
// 6. Log AI usage
await logAIUsage({
userId,
action: 'EVALUATION_SUMMARY',
entityType: 'Project',
entityId: projectId,
model,
promptTokens: 0, // Detailed breakdown not always available
completionTokens: 0,
totalTokens: tokensUsed,
itemsProcessed: evaluations.length,
status: 'SUCCESS',
})
return {
id: summary.id,
projectId: summary.projectId,
roundId: summary.roundId,
summaryJson: summaryJson as AIResponsePayload & { scoringPatterns: ScoringPatterns },
generatedAt: summary.generatedAt,
model: summary.model,
tokensUsed: summary.tokensUsed,
}
}

View File

@ -18,7 +18,7 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
import { import {
anonymizeProjectsForAI, anonymizeProjectsForAI,
validateAnonymizedProjects, validateAnonymizedProjects,
type ProjectWithRelations, toProjectWithRelations,
type AnonymizedProjectForAI, type AnonymizedProjectForAI,
type ProjectAIMapping, type ProjectAIMapping,
} from './anonymization' } from './anonymization'
@ -275,32 +275,6 @@ interface AIScreeningResult {
spamRisk: boolean spamRisk: boolean
} }
/**
* Convert project to enhanced format for anonymization
*/
function toProjectWithRelations(project: ProjectForFiltering): ProjectWithRelations {
return {
id: project.id,
title: project.title,
description: project.description,
competitionCategory: project.competitionCategory as any,
oceanIssue: project.oceanIssue as any,
country: project.country,
geographicZone: project.geographicZone,
institution: project.institution,
tags: project.tags,
foundedAt: project.foundedAt,
wantsMentorship: project.wantsMentorship ?? false,
submissionSource: project.submissionSource ?? 'MANUAL',
submittedAt: project.submittedAt,
_count: {
teamMembers: project._count?.teamMembers ?? 0,
files: project.files?.length ?? 0,
},
files: project.files?.map(f => ({ fileType: f.fileType ?? null })) ?? [],
}
}
/** /**
* Execute AI screening on a batch of projects * Execute AI screening on a batch of projects
*/ */

View File

@ -22,9 +22,8 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
import { import {
anonymizeProjectsForAI, anonymizeProjectsForAI,
validateAnonymizedProjects, validateAnonymizedProjects,
type ProjectWithRelations, toProjectWithRelations,
type AnonymizedProjectForAI, type AnonymizedProjectForAI,
type ProjectAIMapping,
} from './anonymization' } from './anonymization'
// ─── Types ────────────────────────────────────────────────────────────────── // ─── Types ──────────────────────────────────────────────────────────────────
@ -43,14 +42,6 @@ export interface TaggingResult {
tokensUsed: number tokensUsed: number
} }
export interface BatchTaggingResult {
processed: number
failed: number
skipped: number
errors: string[]
results: TaggingResult[]
}
interface AvailableTag { interface AvailableTag {
id: string id: string
name: string name: string
@ -60,8 +51,6 @@ interface AvailableTag {
// ─── Constants ─────────────────────────────────────────────────────────────── // ─── Constants ───────────────────────────────────────────────────────────────
const DEFAULT_BATCH_SIZE = 10
const MAX_BATCH_SIZE = 25
const CONFIDENCE_THRESHOLD = 0.5 const CONFIDENCE_THRESHOLD = 0.5
const DEFAULT_MAX_TAGS = 5 const DEFAULT_MAX_TAGS = 5
@ -138,48 +127,6 @@ export async function getAvailableTags(): Promise<AvailableTag[]> {
}) })
} }
/**
* Convert project to format for anonymization
*/
function toProjectWithRelations(project: {
id: string
title: string
description?: string | null
competitionCategory?: string | null
oceanIssue?: string | null
country?: string | null
geographicZone?: string | null
institution?: string | null
tags: string[]
foundedAt?: Date | null
wantsMentorship?: boolean
submissionSource?: string
submittedAt?: Date | null
_count?: { teamMembers?: number; files?: number }
files?: Array<{ fileType: string | null }>
}): ProjectWithRelations {
return {
id: project.id,
title: project.title,
description: project.description,
competitionCategory: project.competitionCategory as any,
oceanIssue: project.oceanIssue as any,
country: project.country,
geographicZone: project.geographicZone,
institution: project.institution,
tags: project.tags,
foundedAt: project.foundedAt,
wantsMentorship: project.wantsMentorship ?? false,
submissionSource: (project.submissionSource as any) ?? 'MANUAL',
submittedAt: project.submittedAt,
_count: {
teamMembers: project._count?.teamMembers ?? 0,
files: project._count?.files ?? 0,
},
files: project.files?.map((f) => ({ fileType: (f.fileType as any) ?? null })) ?? [],
}
}
// ─── AI Tagging Core ───────────────────────────────────────────────────────── // ─── AI Tagging Core ─────────────────────────────────────────────────────────
/** /**
@ -406,254 +353,6 @@ export async function tagProject(
} }
} }
/**
* Common validation and setup for batch tagging
*/
async function validateBatchTagging(): Promise<{
valid: boolean
error?: string
availableTags?: AvailableTag[]
}> {
const settings = await getTaggingSettings()
console.log('[AI Tagging] Settings:', settings)
if (!settings.enabled) {
console.log('[AI Tagging] AI tagging is disabled in settings')
return {
valid: false,
error: 'AI tagging is disabled. Enable it in Settings > AI or set ai_enabled to true.',
}
}
// Check if OpenAI is configured
const openai = await getOpenAI()
if (!openai) {
console.log('[AI Tagging] OpenAI is not configured')
return {
valid: false,
error: 'OpenAI API is not configured. Add your API key in Settings > AI.',
}
}
// Check if there are any available tags
const availableTags = await getAvailableTags()
console.log(`[AI Tagging] Found ${availableTags.length} available expertise tags`)
if (availableTags.length === 0) {
return {
valid: false,
error: 'No expertise tags defined. Create tags in Settings > Tags first.',
}
}
return { valid: true, availableTags }
}
/**
* Batch tag all untagged projects in a round
*
* Only processes projects with zero tags.
*/
export async function batchTagProjects(
roundId: string,
userId?: string,
onProgress?: (processed: number, total: number) => void
): Promise<BatchTaggingResult> {
const validation = await validateBatchTagging()
if (!validation.valid) {
return {
processed: 0,
failed: 0,
skipped: 0,
errors: [validation.error!],
results: [],
}
}
// Get ALL projects in round to check their tag status
const allProjects = await prisma.project.findMany({
where: { roundId },
include: {
files: { select: { fileType: true } },
_count: { select: { teamMembers: true, files: true } },
},
})
console.log(`[AI Tagging] Found ${allProjects.length} total projects in round`)
// Filter to only projects that truly have no tags (empty tags array)
const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
const alreadyTaggedCount = allProjects.length - untaggedProjects.length
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
if (untaggedProjects.length === 0) {
return {
processed: 0,
failed: 0,
skipped: alreadyTaggedCount,
errors: alreadyTaggedCount > 0
? []
: ['No projects found in this round'],
results: [],
}
}
const results: TaggingResult[] = []
let processed = 0
let failed = 0
const errors: string[] = []
console.log(`[AI Tagging] Starting batch processing of ${untaggedProjects.length} projects in round...`)
const startTime = Date.now()
for (let i = 0; i < untaggedProjects.length; i++) {
const project = untaggedProjects[i]
const projectStartTime = Date.now()
console.log(`[AI Tagging] Processing project ${i + 1}/${untaggedProjects.length}: "${project.title.substring(0, 50)}..."`)
try {
const result = await tagProject(project.id, userId)
results.push(result)
processed++
const elapsed = ((Date.now() - projectStartTime) / 1000).toFixed(1)
console.log(`[AI Tagging] ✓ Tagged "${project.title.substring(0, 30)}..." with ${result.applied.length} tags (${elapsed}s)`)
} catch (error) {
failed++
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
errors.push(`${project.title}: ${errorMsg}`)
console.error(`[AI Tagging] ✗ Failed "${project.title.substring(0, 30)}...": ${errorMsg}`)
}
// Report progress
if (onProgress) {
onProgress(i + 1, untaggedProjects.length)
}
// Log progress every 10 projects
if ((i + 1) % 10 === 0) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0)
const avgTime = (Date.now() - startTime) / (i + 1) / 1000
const remaining = avgTime * (untaggedProjects.length - i - 1)
console.log(`[AI Tagging] Progress: ${i + 1}/${untaggedProjects.length} (${elapsed}s elapsed, ~${remaining.toFixed(0)}s remaining)`)
}
}
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1)
console.log(`[AI Tagging] Batch complete: ${processed} tagged, ${failed} failed, ${alreadyTaggedCount} skipped in ${totalTime}s`)
return {
processed,
failed,
skipped: alreadyTaggedCount,
errors,
results,
}
}
/**
* Batch tag all untagged projects in an entire program (edition)
*
* Processes all projects across all rounds in the program.
*/
export async function batchTagProgramProjects(
programId: string,
userId?: string,
onProgress?: (processed: number, total: number) => void
): Promise<BatchTaggingResult> {
const validation = await validateBatchTagging()
if (!validation.valid) {
return {
processed: 0,
failed: 0,
skipped: 0,
errors: [validation.error!],
results: [],
}
}
// Get ALL projects in the program (across all rounds)
const allProjects = await prisma.project.findMany({
where: {
round: { programId },
},
include: {
files: { select: { fileType: true } },
_count: { select: { teamMembers: true, files: true } },
},
})
console.log(`[AI Tagging] Found ${allProjects.length} total projects in program`)
// Filter to only projects that truly have no tags (empty tags array)
const untaggedProjects = allProjects.filter(p => p.tags.length === 0)
const alreadyTaggedCount = allProjects.length - untaggedProjects.length
console.log(`[AI Tagging] ${untaggedProjects.length} untagged projects, ${alreadyTaggedCount} already have tags`)
if (untaggedProjects.length === 0) {
return {
processed: 0,
failed: 0,
skipped: alreadyTaggedCount,
errors: alreadyTaggedCount > 0
? []
: ['No projects found in this program'],
results: [],
}
}
const results: TaggingResult[] = []
let processed = 0
let failed = 0
const errors: string[] = []
console.log(`[AI Tagging] Starting batch processing of ${untaggedProjects.length} projects...`)
const startTime = Date.now()
for (let i = 0; i < untaggedProjects.length; i++) {
const project = untaggedProjects[i]
const projectStartTime = Date.now()
console.log(`[AI Tagging] Processing project ${i + 1}/${untaggedProjects.length}: "${project.title.substring(0, 50)}..."`)
try {
const result = await tagProject(project.id, userId)
results.push(result)
processed++
const elapsed = ((Date.now() - projectStartTime) / 1000).toFixed(1)
console.log(`[AI Tagging] ✓ Tagged "${project.title.substring(0, 30)}..." with ${result.applied.length} tags (${elapsed}s)`)
} catch (error) {
failed++
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
errors.push(`${project.title}: ${errorMsg}`)
console.error(`[AI Tagging] ✗ Failed "${project.title.substring(0, 30)}...": ${errorMsg}`)
}
// Report progress
if (onProgress) {
onProgress(i + 1, untaggedProjects.length)
}
// Log progress every 10 projects
if ((i + 1) % 10 === 0) {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0)
const avgTime = (Date.now() - startTime) / (i + 1) / 1000
const remaining = avgTime * (untaggedProjects.length - i - 1)
console.log(`[AI Tagging] Progress: ${i + 1}/${untaggedProjects.length} (${elapsed}s elapsed, ~${remaining.toFixed(0)}s remaining)`)
}
}
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1)
console.log(`[AI Tagging] Batch complete: ${processed} tagged, ${failed} failed, ${alreadyTaggedCount} skipped in ${totalTime}s`)
return {
processed,
failed,
skipped: alreadyTaggedCount,
errors,
results,
}
}
/** /**
* Get tag suggestions for a project without applying them * Get tag suggestions for a project without applying them
* Useful for preview/review before applying * Useful for preview/review before applying

View File

@ -132,6 +132,51 @@ export interface ProjectAIMapping {
realId: string realId: string
} }
// ─── Project Conversion Helper ──────────────────────────────────────────────
/**
* Convert a loosely-typed Prisma project result to ProjectWithRelations.
* Used by ai-tagging, ai-filtering, and ai-award-eligibility services.
*/
export function toProjectWithRelations(project: {
id: string
title: string
description?: string | null
competitionCategory?: string | null
oceanIssue?: string | null
country?: string | null
geographicZone?: string | null
institution?: string | null
tags: string[]
foundedAt?: Date | null
wantsMentorship?: boolean | null
submissionSource?: string
submittedAt?: Date | null
_count?: { teamMembers?: number; files?: number }
files?: Array<{ fileType?: string | null; [key: string]: unknown }>
}): ProjectWithRelations {
return {
id: project.id,
title: project.title,
description: project.description,
competitionCategory: project.competitionCategory as ProjectWithRelations['competitionCategory'],
oceanIssue: project.oceanIssue as ProjectWithRelations['oceanIssue'],
country: project.country,
geographicZone: project.geographicZone,
institution: project.institution,
tags: project.tags,
foundedAt: project.foundedAt,
wantsMentorship: project.wantsMentorship ?? false,
submissionSource: (project.submissionSource as ProjectWithRelations['submissionSource']) ?? 'MANUAL',
submittedAt: project.submittedAt,
_count: {
teamMembers: project._count?.teamMembers ?? 0,
files: project._count?.files ?? project.files?.length ?? 0,
},
files: project.files?.map((f) => ({ fileType: (f.fileType as FileType) ?? null })) ?? [],
}
}
// ─── Basic Anonymization (Assignment Service) ──────────────────────────────── // ─── Basic Anonymization (Assignment Service) ────────────────────────────────
interface JurorInput { interface JurorInput {

View File

@ -0,0 +1,178 @@
import { prisma } from '@/lib/prisma'
import { sendStyledNotificationEmail } from '@/lib/email'
const REMINDER_TYPES = [
{ type: '3_DAYS', thresholdMs: 3 * 24 * 60 * 60 * 1000 },
{ type: '24H', thresholdMs: 24 * 60 * 60 * 1000 },
{ type: '1H', thresholdMs: 60 * 60 * 1000 },
] as const
type ReminderType = (typeof REMINDER_TYPES)[number]['type']
interface ReminderResult {
sent: number
errors: number
}
/**
* Find active rounds with approaching voting deadlines and send reminders
* to jurors who have incomplete assignments.
*/
export async function processEvaluationReminders(roundId?: string): Promise<ReminderResult> {
const now = new Date()
let totalSent = 0
let totalErrors = 0
// Find active rounds with voting end dates in the future
const rounds = await prisma.round.findMany({
where: {
status: 'ACTIVE',
votingEndAt: { gt: now },
votingStartAt: { lte: now },
...(roundId && { id: roundId }),
},
select: {
id: true,
name: true,
votingEndAt: true,
program: { select: { name: true } },
},
})
for (const round of rounds) {
if (!round.votingEndAt) continue
const msUntilDeadline = round.votingEndAt.getTime() - now.getTime()
// Determine which reminder types should fire for this round
const applicableTypes = REMINDER_TYPES.filter(
({ thresholdMs }) => msUntilDeadline <= thresholdMs
)
if (applicableTypes.length === 0) continue
for (const { type } of applicableTypes) {
const result = await sendRemindersForRound(round, type, now)
totalSent += result.sent
totalErrors += result.errors
}
}
return { sent: totalSent, errors: totalErrors }
}
async function sendRemindersForRound(
round: {
id: string
name: string
votingEndAt: Date | null
program: { name: string }
},
type: ReminderType,
now: Date
): Promise<ReminderResult> {
let sent = 0
let errors = 0
if (!round.votingEndAt) return { sent, errors }
// Find jurors with incomplete assignments for this round
const incompleteAssignments = await prisma.assignment.findMany({
where: {
roundId: round.id,
isCompleted: false,
},
select: {
userId: true,
},
})
// Get unique user IDs with incomplete work
const userIds = [...new Set(incompleteAssignments.map((a) => a.userId))]
if (userIds.length === 0) return { sent, errors }
// Check which users already received this reminder type for this round
const existingReminders = await prisma.reminderLog.findMany({
where: {
roundId: round.id,
type,
userId: { in: userIds },
},
select: { userId: true },
})
const alreadySent = new Set(existingReminders.map((r) => r.userId))
const usersToNotify = userIds.filter((id) => !alreadySent.has(id))
if (usersToNotify.length === 0) return { sent, errors }
// Get user details and their pending counts
const users = await prisma.user.findMany({
where: { id: { in: usersToNotify } },
select: { id: true, name: true, email: true },
})
const baseUrl = process.env.NEXTAUTH_URL || 'https://monaco-opc.com'
const deadlineStr = round.votingEndAt.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short',
})
// Map to get pending count per user
const pendingCounts = new Map<string, number>()
for (const a of incompleteAssignments) {
pendingCounts.set(a.userId, (pendingCounts.get(a.userId) || 0) + 1)
}
// Select email template type based on reminder type
const emailTemplateType = type === '1H' ? 'REMINDER_1H' : 'REMINDER_24H'
for (const user of users) {
const pendingCount = pendingCounts.get(user.id) || 0
if (pendingCount === 0) continue
try {
await sendStyledNotificationEmail(
user.email,
user.name || '',
emailTemplateType,
{
name: user.name || undefined,
title: `Evaluation Reminder - ${round.name}`,
message: `You have ${pendingCount} pending evaluation${pendingCount !== 1 ? 's' : ''} for ${round.name}.`,
linkUrl: `${baseUrl}/jury/assignments?round=${round.id}`,
metadata: {
pendingCount,
roundName: round.name,
deadline: deadlineStr,
},
}
)
// Log the sent reminder
await prisma.reminderLog.create({
data: {
roundId: round.id,
userId: user.id,
type,
},
})
sent++
} catch (error) {
console.error(
`Failed to send ${type} reminder to ${user.email} for round ${round.name}:`,
error
)
errors++
}
}
return { sent, errors }
}

View File

@ -6,13 +6,18 @@
* - Bio/description match (text similarity) * - Bio/description match (text similarity)
* - Workload balance * - Workload balance
* - Country match (mentors only) * - Country match (mentors only)
* - Geographic diversity penalty (prevents clustering by country)
* - Previous round familiarity bonus (continuity across rounds)
* - COI penalty (conflict of interest hard-block)
* *
* Score Breakdown (100 points max): * Score Breakdown:
* - Tag overlap: 0-40 points (weighted by confidence) * - Tag overlap: 0-40 points (weighted by confidence)
* - Bio match: 0-15 points (if bio exists) * - Bio match: 0-15 points (if bio exists)
* - Workload balance: 0-25 points * - Workload balance: 0-25 points
* - Country match: 0-15 points (mentors only) * - Country match: 0-15 points (mentors only)
* - Reserved: 0-5 points (future AI boost) * - Geo diversity: -15 per excess same-country assignment (threshold: 2)
* - Previous round familiarity: +10 if reviewed in earlier round
* - COI: juror skipped entirely if conflict declared
*/ */
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
@ -24,7 +29,9 @@ export interface ScoreBreakdown {
bioMatch: number bioMatch: number
workloadBalance: number workloadBalance: number
countryMatch: number countryMatch: number
aiBoost: number geoDiversityPenalty: number
previousRoundFamiliarity: number
coiPenalty: number
} }
export interface AssignmentScore { export interface AssignmentScore {
@ -53,6 +60,12 @@ const MAX_WORKLOAD_SCORE = 25
const MAX_COUNTRY_SCORE = 15 const MAX_COUNTRY_SCORE = 15
const POINTS_PER_TAG_MATCH = 8 const POINTS_PER_TAG_MATCH = 8
// New scoring factors
const GEO_DIVERSITY_THRESHOLD = 2
const GEO_DIVERSITY_PENALTY_PER_EXCESS = -15
const PREVIOUS_ROUND_FAMILIARITY_BONUS = 10
// COI jurors are skipped entirely rather than penalized (effectively -Infinity)
// Common words to exclude from bio matching // Common words to exclude from bio matching
const STOP_WORDS = new Set([ const STOP_WORDS = new Set([
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with',
@ -285,10 +298,68 @@ export async function getSmartSuggestions(options: {
existingAssignments.map((a) => `${a.userId}:${a.projectId}`) existingAssignments.map((a) => `${a.userId}:${a.projectId}`)
) )
// Calculate target assignments per user // ── Batch-query data for new scoring factors ──────────────────────────────
// 1. Geographic diversity: per-juror country distribution for existing assignments
const assignmentsWithCountry = await prisma.assignment.findMany({
where: { roundId },
select: {
userId: true,
project: { select: { country: true } },
},
})
// Build map: userId -> { country -> count }
const userCountryDistribution = new Map<string, Map<string, number>>()
for (const a of assignmentsWithCountry) {
const country = a.project.country?.toLowerCase().trim()
if (!country) continue
let countryMap = userCountryDistribution.get(a.userId)
if (!countryMap) {
countryMap = new Map()
userCountryDistribution.set(a.userId, countryMap)
}
countryMap.set(country, (countryMap.get(country) || 0) + 1)
}
// 2. Previous round familiarity: find assignments in earlier rounds of the same program
const currentRound = await prisma.round.findUnique({
where: { id: roundId },
select: { programId: true, sortOrder: true },
})
const previousRoundAssignmentPairs = new Set<string>()
if (currentRound) {
const previousAssignments = await prisma.assignment.findMany({
where: {
round: {
programId: currentRound.programId,
sortOrder: { lt: currentRound.sortOrder },
},
},
select: { userId: true, projectId: true },
})
for (const pa of previousAssignments) {
previousRoundAssignmentPairs.add(`${pa.userId}:${pa.projectId}`)
}
}
// 3. COI declarations: all active conflicts for this round
const coiRecords = await prisma.conflictOfInterest.findMany({
where: {
roundId,
hasConflict: true,
},
select: { userId: true, projectId: true },
})
const coiPairs = new Set(
coiRecords.map((c) => `${c.userId}:${c.projectId}`)
)
// ── Calculate target assignments per user ─────────────────────────────────
const targetPerUser = Math.ceil(projects.length / users.length) const targetPerUser = Math.ceil(projects.length / users.length)
// Calculate scores for all user-project pairs // ── Calculate scores for all user-project pairs ───────────────────────────
const suggestions: AssignmentScore[] = [] const suggestions: AssignmentScore[] = []
for (const user of users) { for (const user of users) {
@ -305,6 +376,11 @@ export async function getSmartSuggestions(options: {
continue continue
} }
// COI check - skip juror entirely for this project if COI declared
if (coiPairs.has(pairKey)) {
continue
}
// Get project tags data // Get project tags data
const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({ const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
tagId: pt.tagId, tagId: pt.tagId,
@ -312,13 +388,12 @@ export async function getSmartSuggestions(options: {
confidence: pt.confidence, confidence: pt.confidence,
})) }))
// Calculate scores // Calculate existing scores
const { score: tagScore, matchingTags } = calculateTagOverlapScore( const { score: tagScore, matchingTags } = calculateTagOverlapScore(
user.expertiseTags, user.expertiseTags,
projectTags projectTags
) )
// Bio match (only if user has a bio)
const { score: bioScore, matchingKeywords } = calculateBioMatchScore( const { score: bioScore, matchingKeywords } = calculateBioMatchScore(
user.bio, user.bio,
project.description project.description
@ -330,13 +405,39 @@ export async function getSmartSuggestions(options: {
user.maxAssignments user.maxAssignments
) )
// Country match only for mentors
const countryScore = const countryScore =
type === 'mentor' type === 'mentor'
? calculateCountryMatchScore(user.country, project.country) ? calculateCountryMatchScore(user.country, project.country)
: 0 : 0
const totalScore = tagScore + bioScore + workloadScore + countryScore // ── New scoring factors ─────────────────────────────────────────────
// Geographic diversity penalty
let geoDiversityPenalty = 0
const projectCountry = project.country?.toLowerCase().trim()
if (projectCountry) {
const countryMap = userCountryDistribution.get(user.id)
const sameCountryCount = countryMap?.get(projectCountry) || 0
if (sameCountryCount >= GEO_DIVERSITY_THRESHOLD) {
geoDiversityPenalty =
GEO_DIVERSITY_PENALTY_PER_EXCESS *
(sameCountryCount - GEO_DIVERSITY_THRESHOLD + 1)
}
}
// Previous round familiarity bonus
let previousRoundFamiliarity = 0
if (previousRoundAssignmentPairs.has(pairKey)) {
previousRoundFamiliarity = PREVIOUS_ROUND_FAMILIARITY_BONUS
}
const totalScore =
tagScore +
bioScore +
workloadScore +
countryScore +
geoDiversityPenalty +
previousRoundFamiliarity
// Build reasoning // Build reasoning
const reasoning: string[] = [] const reasoning: string[] = []
@ -354,6 +455,12 @@ export async function getSmartSuggestions(options: {
if (countryScore > 0) { if (countryScore > 0) {
reasoning.push('Same country') reasoning.push('Same country')
} }
if (geoDiversityPenalty < 0) {
reasoning.push(`Geo diversity penalty (${geoDiversityPenalty})`)
}
if (previousRoundFamiliarity > 0) {
reasoning.push('Reviewed in previous round (+10)')
}
suggestions.push({ suggestions.push({
userId: user.id, userId: user.id,
@ -367,7 +474,9 @@ export async function getSmartSuggestions(options: {
bioMatch: bioScore, bioMatch: bioScore,
workloadBalance: workloadScore, workloadBalance: workloadScore,
countryMatch: countryScore, countryMatch: countryScore,
aiBoost: 0, geoDiversityPenalty,
previousRoundFamiliarity,
coiPenalty: 0, // COI jurors are skipped entirely
}, },
reasoning, reasoning,
matchingTags, matchingTags,
@ -490,7 +599,9 @@ export async function getMentorSuggestionsForProject(
bioMatch: bioScore, bioMatch: bioScore,
workloadBalance: workloadScore, workloadBalance: workloadScore,
countryMatch: countryScore, countryMatch: countryScore,
aiBoost: 0, geoDiversityPenalty: 0,
previousRoundFamiliarity: 0,
coiPenalty: 0,
}, },
reasoning, reasoning,
matchingTags, matchingTags,

View File

@ -17,6 +17,7 @@ export type AIAction =
| 'AWARD_ELIGIBILITY' | 'AWARD_ELIGIBILITY'
| 'MENTOR_MATCHING' | 'MENTOR_MATCHING'
| 'PROJECT_TAGGING' | 'PROJECT_TAGGING'
| 'EVALUATION_SUMMARY'
export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR' export type AIStatus = 'SUCCESS' | 'PARTIAL' | 'ERROR'

View File

@ -1,11 +1,19 @@
import { prisma } from '@/lib/prisma' import { prisma as globalPrisma } from '@/lib/prisma'
import type { Prisma } from '@prisma/client' import type { Prisma, PrismaClient } from '@prisma/client'
/** Minimal Prisma-like client that supports auditLog.create (works with PrismaClient and transaction clients). */
type AuditPrismaClient = Pick<PrismaClient, 'auditLog'>
/** /**
* Shared utility for creating audit log entries. * Shared utility for creating audit log entries.
* Wrapped in try-catch so audit failures never break the calling operation. * Wrapped in try-catch so audit failures never break the calling operation.
*
* @param input.prisma - Optional Prisma client instance. When omitted the global
* singleton is used. Pass `ctx.prisma` from tRPC handlers so audit writes
* participate in the same transaction when applicable.
*/ */
export async function logAudit(input: { export async function logAudit(input: {
prisma?: AuditPrismaClient
userId?: string | null userId?: string | null
action: string action: string
entityType: string entityType: string
@ -15,7 +23,8 @@ export async function logAudit(input: {
userAgent?: string userAgent?: string
}): Promise<void> { }): Promise<void> {
try { try {
await prisma.auditLog.create({ const db = input.prisma ?? globalPrisma
await db.auditLog.create({
data: { data: {
userId: input.userId ?? null, userId: input.userId ?? null,
action: input.action, action: input.action,

View File

@ -0,0 +1,212 @@
import { TRPCError } from '@trpc/server'
import type { PrismaClient } from '@prisma/client'
import { logAudit } from './audit'
import {
getStorageProviderWithType,
createStorageProvider,
getContentType,
isValidImageType,
type StorageProviderType,
} from '@/lib/storage'
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Configuration for an image upload domain (avatar, logo, etc.)
*
* Each config describes how to read/write image keys for a specific entity.
*/
export type ImageUploadConfig<TSelectResult> = {
/** Human-readable label used in log/error messages (e.g. "avatar", "logo") */
label: string
/** Generate a storage object key for a new upload */
generateKey: (entityId: string, fileName: string) => string
/** Prisma select fetch the current image key + provider for the entity */
findCurrent: (
prisma: PrismaClient,
entityId: string
) => Promise<TSelectResult | null>
/** Extract the image key from the select result */
getImageKey: (record: TSelectResult) => string | null
/** Extract the storage provider type from the select result */
getProviderType: (record: TSelectResult) => StorageProviderType
/** Prisma update set the new image key + provider on the entity */
setImage: (
prisma: PrismaClient,
entityId: string,
key: string,
providerType: StorageProviderType
) => Promise<unknown>
/** Prisma update clear the image key + provider on the entity */
clearImage: (prisma: PrismaClient, entityId: string) => Promise<unknown>
/** Audit log entity type (e.g. "User", "Project") */
auditEntityType: string
/** Audit log field name (e.g. "profileImageKey", "logoKey") */
auditFieldName: string
}
type AuditContext = {
userId: string
ip: string
userAgent: string
}
// ---------------------------------------------------------------------------
// Shared operations
// ---------------------------------------------------------------------------
/**
* Get a pre-signed upload URL for an image.
*
* Validates the content type, generates a storage key, and returns the
* upload URL along with the key and provider type.
*/
export async function getImageUploadUrl(
entityId: string,
fileName: string,
contentType: string,
generateKey: (entityId: string, fileName: string) => string
): Promise<{ uploadUrl: string; key: string; providerType: StorageProviderType }> {
if (!isValidImageType(contentType)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP',
})
}
const key = generateKey(entityId, fileName)
const resolvedContentType = getContentType(fileName)
const { provider, providerType } = await getStorageProviderWithType()
const uploadUrl = await provider.getUploadUrl(key, resolvedContentType)
return { uploadUrl, key, providerType }
}
/**
* Confirm an image upload: verify the object exists in storage, delete the
* previous image (if any), persist the new key, and write an audit log entry.
*/
export async function confirmImageUpload<TSelectResult>(
prisma: PrismaClient,
config: ImageUploadConfig<TSelectResult>,
entityId: string,
key: string,
providerType: StorageProviderType,
audit: AuditContext
): Promise<void> {
// 1. Verify upload exists in storage
const provider = createStorageProvider(providerType)
const exists = await provider.objectExists(key)
if (!exists) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Upload not found. Please try uploading again.',
})
}
// 2. Delete old image if present
const current = await config.findCurrent(prisma, entityId)
if (current) {
const oldKey = config.getImageKey(current)
if (oldKey) {
try {
const oldProvider = createStorageProvider(config.getProviderType(current))
await oldProvider.deleteObject(oldKey)
} catch (error) {
console.warn(`Failed to delete old ${config.label}:`, error)
}
}
}
// 3. Persist new image key + provider
await config.setImage(prisma, entityId, key, providerType)
// 4. Audit log
await logAudit({
prisma,
userId: audit.userId,
action: 'UPDATE',
entityType: config.auditEntityType,
entityId,
detailsJson: {
field: config.auditFieldName,
newValue: key,
provider: providerType,
},
ipAddress: audit.ip,
userAgent: audit.userAgent,
})
}
/**
* Get the download URL for an existing image, or null if none is set.
*/
export async function getImageUrl<TSelectResult>(
prisma: PrismaClient,
config: Pick<ImageUploadConfig<TSelectResult>, 'findCurrent' | 'getImageKey' | 'getProviderType'>,
entityId: string
): Promise<string | null> {
const record = await config.findCurrent(prisma, entityId)
if (!record) return null
const imageKey = config.getImageKey(record)
if (!imageKey) return null
const providerType = config.getProviderType(record)
const provider = createStorageProvider(providerType)
return provider.getDownloadUrl(imageKey)
}
/**
* Delete an image from storage and clear the reference in the database.
* Writes an audit log entry.
*/
export async function deleteImage<TSelectResult>(
prisma: PrismaClient,
config: ImageUploadConfig<TSelectResult>,
entityId: string,
audit: AuditContext
): Promise<{ success: true }> {
const record = await config.findCurrent(prisma, entityId)
if (!record) return { success: true }
const imageKey = config.getImageKey(record)
if (!imageKey) return { success: true }
// Delete from storage
const providerType = config.getProviderType(record)
const provider = createStorageProvider(providerType)
try {
await provider.deleteObject(imageKey)
} catch (error) {
console.warn(`Failed to delete ${config.label} from storage:`, error)
}
// Clear in database
await config.clearImage(prisma, entityId)
// Audit log
await logAudit({
prisma,
userId: audit.userId,
action: 'DELETE',
entityType: config.auditEntityType,
entityId,
detailsJson: { field: config.auditFieldName },
ipAddress: audit.ip,
userAgent: audit.userAgent,
})
return { success: true }
}