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 |
| Past Evaluations | Visible read-only after submit |
| Grace Period | Admin-configurable per juror/project |
| Smart Assignment | AI-powered (GPT) + Smart Algorithm fallback |
| Smart Assignment | AI-powered (GPT) + Smart Algorithm fallback + geo-diversity, familiarity, COI scoring |
| AI Data Privacy | All data anonymized before sending to GPT |
| Evaluation Criteria Types | `numeric`, `text`, `boolean`, `section_header` (backward-compatible) |
| COI Workflow | Mandatory declaration before evaluation, admin review |
| Evaluation Reminders | Cron-based email reminders with countdown urgency |
## Brand Identity
@ -83,14 +86,22 @@ mopc-platform/
│ │ ├── (admin)/ # Admin dashboard (protected)
│ │ ├── (jury)/ # Jury interface (protected)
│ │ ├── api/ # API routes
│ │ │ └── trpc/ # tRPC endpoint
│ │ │ ├── trpc/ # tRPC endpoint
│ │ │ └── cron/
│ │ │ └── reminders/ # Cron endpoint for evaluation reminders (F4)
│ │ ├── layout.tsx # Root layout
│ │ └── page.tsx # Home/landing
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ ├── forms/ # Form components (evaluation, etc.)
│ │ ├── admin/ # Admin-specific components
│ │ │ └── evaluation-summary-card.tsx # AI summary display
│ │ ├── forms/ # Form components
│ │ │ ├── evaluation-form.tsx # With progress indicator (F1)
│ │ │ ├── coi-declaration-dialog.tsx # COI blocking dialog (F5)
│ │ │ └── evaluation-form-with-coi.tsx # COI-gated wrapper (F5)
│ │ ├── layouts/ # Layout components (sidebar, nav)
│ │ └── shared/ # Shared components
│ │ └── countdown-timer.tsx # Live countdown with urgency (F4)
│ ├── lib/
│ │ ├── auth.ts # NextAuth configuration
│ │ ├── prisma.ts # Prisma client singleton
@ -107,8 +118,14 @@ mopc-platform/
│ │ │ ├── evaluation.ts
│ │ │ ├── audit.ts
│ │ │ ├── settings.ts
│ │ │ └── gracePeriod.ts
│ │ │ ├── gracePeriod.ts
│ │ │ ├── export.ts # CSV export incl. filtering results (F2)
│ │ │ ├── analytics.ts # Reports/analytics (observer access, F3)
│ │ │ └── mentor.ts # Mentor messaging endpoints (F10)
│ │ ├── services/ # Business logic services
│ │ │ ├── smart-assignment.ts # With geo/familiarity/COI scoring (F8)
│ │ │ ├── evaluation-reminders.ts # Email reminder service (F4)
│ │ │ └── ai-evaluation-summary.ts # GPT summary generation (F7)
│ │ └── middleware/ # RBAC & auth middleware
│ ├── hooks/ # React hooks
│ ├── types/ # Shared TypeScript types
@ -240,8 +257,11 @@ SMTP_USER="noreply@monaco-opc.com"
SMTP_PASS="your-smtp-password"
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
# OpenAI (for smart assignment)
# OpenAI (for smart assignment and AI evaluation summaries)
OPENAI_API_KEY="your-openai-api-key"
# Cron (for scheduled evaluation reminders)
CRON_SECRET="your-cron-secret-key"
```
## Key Architectural Decisions
@ -273,8 +293,10 @@ OPENAI_API_KEY="your-openai-api-key"
|------|------------|
| **SUPER_ADMIN** | Full system access, all programs, user management |
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
| **JURY_MEMBER** | View assigned projects only, submit evaluations |
| **OBSERVER** | Read-only access to dashboards (optional) |
| **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI |
| **OBSERVER** | Read-only access to dashboards, all analytics/reports |
| **MENTOR** | View assigned projects, message applicants via `mentorProcedure` |
| **APPLICANT** | View own project status, upload documents per round, message mentor |
## Important Constraints
@ -286,6 +308,12 @@ OPENAI_API_KEY="your-openai-api-key"
6. **Mobile responsiveness is mandatory** - every view must work on phones
7. **File downloads require project authorization** - jury/mentor must be assigned to the project
8. **Mentor endpoints require MENTOR role** - uses `mentorProcedure` middleware
9. **COI declaration required before evaluation** - blocking dialog gates evaluation form; admin reviews COI declarations
10. **Evaluation form supports multiple criterion types** - `numeric`, `text`, `boolean`, `section_header`; defaults to `numeric` for backward compatibility
11. **Smart assignment respects COI** - jurors with declared conflicts are skipped entirely; geo-diversity penalty and prior-round familiarity bonus applied
12. **Cron endpoints protected by CRON_SECRET** - `/api/cron/reminders` validates secret header
13. **Project status changes tracked** - every status update creates a `ProjectStatusHistory` record
14. **Per-round document management** - `ProjectFile` supports `roundId` scoping and `isLate` deadline tracking
## Security Notes
@ -326,9 +354,18 @@ The MOPC platform connects to these via environment variables.
- Progress dashboards
- CSV export
- Audit logging
- **F1: Evaluation progress indicator** - sticky status bar with percentage tracking across criteria, global score, decision, feedback
- **F2: Export filtering results as CSV** - dynamic AI column flattening from `aiScreeningJson`
- **F3: Observer access to reports/analytics** - all 8 analytics procedures use `observerProcedure`; observer reports page with round selector, tabs, charts
- **F4: Countdown timer + email reminders** - live countdown with urgency colors; `EvaluationRemindersService` with cron endpoint (`/api/cron/reminders`)
- **F5: Conflict of Interest declaration** - `ConflictOfInterest` model; blocking dialog before evaluation; admin COI review page
- **F6: Bulk status update UI** - checkbox selection, floating toolbar, `ProjectStatusHistory` tracking
- **F7: AI-powered evaluation summary** - `EvaluationSummary` model; GPT-generated strengths/weaknesses, themes, scoring stats
- **F8: Smart assignment improvements** - `geoDiversityPenalty`, `previousRoundFamiliarity`, `coiPenalty` scoring factors
- **F9: Evaluation form flexibility** - extended criterion types (`numeric`, `text`, `boolean`, `section_header`); conditional visibility, section grouping
- **F10: Applicant portal enhancements** - `ProjectStatusHistory` timeline; per-round document management (`roundId` + `isLate` on `ProjectFile`); `MentorMessage` model for mentor-applicant chat
### Out of Scope (Phase 2+)
- Auto-assignment algorithm
- Typeform/Notion integrations
- WhatsApp notifications
- Learning hub

View File

@ -25,7 +25,13 @@ RUN npx prisma generate
COPY . .
# Build the application at build time (pre-compile everything)
# Dummy env vars needed so Next.js can collect page data without MinIO throwing
ENV MINIO_ACCESS_KEY=build-placeholder
ENV MINIO_SECRET_KEY=build-placeholder
RUN npm run build
# Clear build-time placeholders (runtime uses real values from docker-compose)
ENV MINIO_ACCESS_KEY=
ENV MINIO_SECRET_KEY=
# Expose port
EXPOSE 3000

View File

@ -65,16 +65,25 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty
│ PRESENTATION LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Admin Views │ │ Jury Views │ │ Auth Views │ │
│ │ │ │ │ │ │ │
│ │ - Dashboard │ │ - Project List │ │ - Login │ │
│ │ - Rounds │ │ - Project View │ │ - Magic Link │ │
│ │ - Projects │ │ - Evaluation │ │ - Verify │ │
│ │ - Jury Mgmt │ │ - My Progress │ │ │ │
│ │ - Assignments │ │ │ │ │ │
│ │ - Reports │ │ │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Admin Views │ │ Jury Views │ │Applicant View│ │ Mentor Views │ │
│ │ │ │ │ │ │ │ │ │
│ │ - Dashboard │ │ - Project Ls │ │ - Status │ │ - Assigned │ │
│ │ - Rounds │ │ - Project Vw │ │ Tracker │ │ Projects │ │
│ │ - Projects │ │ - Evaluation │ │ - Document │ │ - Messaging │ │
│ │ - Jury Mgmt │ │ - My Progress│ │ Uploads │ │ │ │
│ │ - Assignments│ │ - COI Decl. │ │ - Mentor │ │ │ │
│ │ - Reports │ │ - Countdown │ │ Chat │ │ │ │
│ │ - COI Review │ │ │ │ │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Auth Views │ │ Observer Views │ │
│ │ │ │ │ │
│ │ - Login │ │ - Reports/ │ │
│ │ - Magic Link │ │ Analytics │ │
│ │ - Verify │ │ - Round Stats │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ Built with: Next.js App Router + React Server Components + shadcn/ui │
└─────────────────────────────────────────────────────────────────────────────┘
@ -116,6 +125,11 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty
│ │ Service │ │ Service │ │ Service │ │ Service │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
│ │
│ ┌────────────┐ ┌────────────┐ │
│ │ Evaluation │ │ AI Eval │ │
│ │ Reminders │ │ Summary │ │
│ └────────────┘ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
@ -151,8 +165,11 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty
| Component | Responsibility |
|-----------|----------------|
| **Admin Views** | Program/round management, project import, jury management, assignments, dashboards |
| **Jury Views** | View assigned projects, evaluate projects, track progress |
| **Admin Views** | Program/round management, project import, jury management, assignments, dashboards, COI review, bulk status updates, AI evaluation summaries |
| **Jury Views** | View assigned projects, evaluate projects, track progress, COI declarations, countdown timer |
| **Applicant Views** | Project status tracker, document uploads (per-round with deadline policy), mentor chat |
| **Mentor Views** | View assigned projects, messaging with applicants |
| **Observer Views** | Read-only access to all reports/analytics (round selector, tabs, all chart components) |
| **Auth Views** | Login, magic link verification, session management |
| **Layouts** | Responsive navigation, sidebar, mobile adaptations |
| **UI Components** | shadcn/ui based, reusable, accessible |
@ -177,8 +194,10 @@ The MOPC Platform is a secure jury voting system built as a modern full-stack Ty
| **EvaluationService** | Form submission, autosave, scoring |
| **FileService** | MinIO uploads, pre-signed URLs |
| **EmailService** | Magic links, notifications via Nodemailer |
| **ExportService** | CSV/Excel generation |
| **ExportService** | CSV/Excel generation, filtering results export with AI column flattening |
| **AuditService** | Immutable event logging |
| **EvaluationRemindersService** | Finds incomplete assignments, sends email reminders with countdown urgency |
| **AIEvaluationSummaryService** | Anonymizes evaluation data, generates GPT-powered summaries with scoring patterns, strengths/weaknesses |
### Data Layer
@ -345,8 +364,14 @@ The platform includes two assignment modes:
```
Score = (expertise_match × 40) + (load_balance × 25) +
(specialty_match × 20) + (diversity × 10) - (conflict × 100)
- (geo_diversity_penalty) + (previous_round_familiarity) - (coi_penalty)
```
**Additional Scoring Factors** (added in Phase 1 enhancements):
- **geoDiversityPenalty**: -15 per excess same-country assignment beyond threshold of 2
- **previousRoundFamiliarity**: +10 bonus for jurors who reviewed the same project in a prior round
- **coiPenalty**: Jurors with a declared Conflict of Interest are skipped entirely
## Admin Settings Panel
Centralized configuration for:
@ -363,8 +388,10 @@ Centralized configuration for:
|------|------------|
| **SUPER_ADMIN** | Full system access, all programs, user management |
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
| **JURY_MEMBER** | View assigned projects only, submit evaluations |
| **OBSERVER** | Read-only access to dashboards |
| **JURY_MEMBER** | View assigned projects only, submit evaluations, declare COI |
| **OBSERVER** | Read-only access to dashboards and all analytics/reports |
| **MENTOR** | View assigned projects, message applicants via `mentorProcedure` |
| **APPLICANT** | View own project status, upload documents per round, message mentor |
## Related Documentation

View File

@ -119,6 +119,9 @@ export const hasRole = (...roles: UserRole[]) =>
export const protectedProcedure = t.procedure.use(isAuthenticated)
export const adminProcedure = t.procedure.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN'))
export const juryProcedure = t.procedure.use(hasRole('JURY_MEMBER'))
export const observerProcedure = t.procedure.use(hasRole('SUPER_ADMIN', 'PROGRAM_ADMIN', 'OBSERVER'))
export const mentorProcedure = t.procedure.use(hasRole('MENTOR'))
export const applicantProcedure = t.procedure.use(hasRole('APPLICANT'))
```
## Router Structure
@ -136,7 +139,9 @@ src/server/routers/
├── export.ts # Export operations
├── audit.ts # Audit log access
├── settings.ts # Platform settings (admin)
└── gracePeriod.ts # Grace period management
├── gracePeriod.ts # Grace period management
├── analytics.ts # Reports & analytics (admin + observer)
└── mentor.ts # Mentor messaging endpoints
```
### Root Router
@ -1024,6 +1029,164 @@ export const gracePeriodRouter = router({
})
```
### Conflict of Interest Endpoints (Evaluation Router)
```typescript
// Added to src/server/routers/evaluation.ts
// Declare COI for an assignment (jury member)
declareCOI: protectedProcedure
.input(z.object({
assignmentId: z.string(),
hasConflict: z.boolean(),
conflictType: z.string().optional(),
description: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
// Creates/updates ConflictOfInterest record
// Blocks evaluation access if hasConflict = true until reviewed
}),
// Get COI status for an assignment
getCOIStatus: protectedProcedure
.input(z.object({ assignmentId: z.string() }))
.query(async ({ ctx, input }) => {
// Returns COI declaration status for the assignment
}),
// List all COI declarations for a round (admin only)
listCOIByRound: adminProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
// Returns all COI declarations with user and project info
}),
// Review a COI declaration (admin only)
reviewCOI: adminProcedure
.input(z.object({
id: z.string(),
reviewNotes: z.string().optional(),
}))
.mutation(async ({ ctx, input }) => {
// Marks COI as reviewed, logs audit event
}),
```
### AI Evaluation Summary Endpoints (Evaluation Router)
```typescript
// Added to src/server/routers/evaluation.ts
// Generate AI summary for a project's evaluations
generateSummary: adminProcedure
.input(z.object({
projectId: z.string(),
roundId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
// Anonymizes evaluation data, sends to GPT
// Generates strengths/weaknesses, themes, scoring patterns
// Stores EvaluationSummary record
}),
// Get existing summary for a project
getSummary: adminProcedure
.input(z.object({
projectId: z.string(),
roundId: z.string(),
}))
.query(async ({ ctx, input }) => {
// Returns EvaluationSummary with parsed summaryJson
}),
// Generate summaries for all projects in a round
generateBulkSummaries: adminProcedure
.input(z.object({
roundId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
// Iterates through projects, generates summaries in batch
// Returns count of generated summaries
}),
```
### Evaluation Reminders Endpoint
```typescript
// Added to src/server/routers/evaluation.ts
// Trigger reminder emails for incomplete assignments (admin)
triggerReminders: adminProcedure
.input(z.object({
roundId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
// Calls EvaluationRemindersService
// Finds incomplete assignments, sends email reminders
// Logs to ReminderLog table
}),
```
### Cron API Route
```typescript
// src/app/api/cron/reminders/route.ts
// POST /api/cron/reminders
// Protected by CRON_SECRET header validation
// Automatically finds rounds with approaching deadlines
// Sends reminder emails to jurors with incomplete evaluations
// Designed to be called by external cron scheduler
```
### Export Router - Filtering Results
```typescript
// Added to src/server/routers/export.ts
// Export filtering results as CSV
filteringResults: adminProcedure
.input(z.object({
roundId: z.string(),
}))
.query(async ({ ctx, input }) => {
// Queries projects with evaluations and AI screening data
// Dynamically flattens aiScreeningJson columns
// Returns CSV-ready data
}),
```
### Evaluation Form Schema - Extended Criterion Types
The evaluation form `criteriaJson` now supports extended criterion types:
```typescript
type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
type Criterion = {
id: string
label: string
type: CriterionType // Defaults to 'numeric' for backward compatibility
scale?: string // Only for 'numeric' type
weight?: number // Only for 'numeric' type
required?: boolean
description?: string
// Conditional visibility
visibleWhen?: {
criterionId: string
value: unknown
}
// Section grouping
section?: string
}
```
Field components per type:
- **numeric**: Standard slider/number input with scale
- **text**: Free-text textarea field (`TextCriterionField`)
- **boolean**: Yes/No toggle (`BooleanCriterionField`)
- **section_header**: Non-input visual divider for form organization (`SectionHeader`)
## Authentication Flow
### Magic Link Implementation

View File

@ -112,6 +112,72 @@ The MOPC platform uses PostgreSQL as its primary database, accessed via Prisma O
│ grantedBy │
│ createdAt │
└─────────────────────┘
┌─────────────────────┐
│ ReminderLog │
│─────────────────────│
│ id │
│ roundId │
│ userId │
│ assignmentId │
│ type │
│ sentAt │
│ emailTo │
│ status │
└─────────────────────┘
┌───────────────────────┐
│ ConflictOfInterest │
│───────────────────────│
│ id │
│ assignmentId │
│ userId │
│ projectId │
│ roundId │
│ hasConflict │
│ conflictType │
│ description │
│ reviewedBy │
│ reviewedAt │
│ reviewNotes │
│ createdAt │
└───────────────────────┘
┌───────────────────────┐
│ EvaluationSummary │
│───────────────────────│
│ id │
│ projectId │
│ roundId │
│ summaryJson │
│ model │
│ tokensUsed │
│ createdAt │
└───────────────────────┘
┌───────────────────────┐
│ ProjectStatusHistory │
│───────────────────────│
│ id │
│ projectId │
│ oldStatus │
│ newStatus │
│ changedBy │
│ reason │
│ createdAt │
└───────────────────────┘
┌───────────────────────┐
│ MentorMessage │
│───────────────────────│
│ id │
│ projectId │
│ senderId │
│ recipientId │
│ content │
│ readAt │
│ createdAt │
└───────────────────────┘
```
## Prisma Schema
@ -137,6 +203,8 @@ enum UserRole {
PROGRAM_ADMIN
JURY_MEMBER
OBSERVER
MENTOR
APPLICANT
}
enum UserStatus {
@ -357,12 +425,14 @@ model Project {
model ProjectFile {
id String @id @default(cuid())
projectId String
roundId String? // Per-round document management (Phase B)
// File info
fileType FileType
fileName String
mimeType String
size Int // bytes
isLate Boolean @default(false) // Upload deadline policy tracking
// MinIO location
bucket String
@ -376,6 +446,7 @@ model ProjectFile {
// Indexes
@@index([projectId])
@@index([fileType])
@@index([roundId])
@@unique([bucket, objectKey])
}
@ -475,6 +546,119 @@ model AuditLog {
@@index([entityType, entityId])
@@index([timestamp])
}
// =============================================================================
// REMINDER LOGGING
// =============================================================================
model ReminderLog {
id String @id @default(cuid())
roundId String
userId String
assignmentId String
type String // e.g., "DEADLINE_APPROACHING", "OVERDUE"
sentAt DateTime @default(now())
emailTo String
status String // "SENT", "FAILED"
// Indexes
@@index([roundId])
@@index([userId])
@@index([assignmentId])
}
// =============================================================================
// CONFLICT OF INTEREST
// =============================================================================
model ConflictOfInterest {
id String @id @default(cuid())
assignmentId String
userId String
projectId String
roundId String
hasConflict Boolean
conflictType String? // e.g., "FINANCIAL", "PERSONAL", "ORGANIZATIONAL"
description String? @db.Text
// Review fields (admin)
reviewedBy String?
reviewedAt DateTime?
reviewNotes String? @db.Text
createdAt DateTime @default(now())
// Relations
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
// Indexes
@@unique([assignmentId])
@@index([userId])
@@index([roundId])
@@index([hasConflict])
}
// =============================================================================
// AI EVALUATION SUMMARY
// =============================================================================
model EvaluationSummary {
id String @id @default(cuid())
projectId String
roundId String
summaryJson Json @db.JsonB // Strengths, weaknesses, themes, scoring stats
model String // e.g., "gpt-4o"
tokensUsed Int
createdAt DateTime @default(now())
// Indexes
@@unique([projectId, roundId])
@@index([roundId])
}
// =============================================================================
// PROJECT STATUS HISTORY
// =============================================================================
model ProjectStatusHistory {
id String @id @default(cuid())
projectId String
oldStatus String
newStatus String
changedBy String? // User who changed the status
reason String? @db.Text
createdAt DateTime @default(now())
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
// Indexes
@@index([projectId])
@@index([createdAt])
}
// =============================================================================
// MENTOR MESSAGING
// =============================================================================
model MentorMessage {
id String @id @default(cuid())
projectId String
senderId String
recipientId String
content String @db.Text
readAt DateTime?
createdAt DateTime @default(now())
// Indexes
@@index([projectId])
@@index([senderId])
@@index([recipientId])
@@index([createdAt])
}
```
## Indexing Strategy
@ -505,6 +689,22 @@ model AuditLog {
| Evaluation | `submittedAt` | Sort by submission time |
| AuditLog | `timestamp` | Time-based queries |
| AuditLog | `entityType, entityId` | Entity history |
| ProjectFile | `roundId` | Per-round document listing |
| ReminderLog | `roundId` | Reminders per round |
| ReminderLog | `userId` | User reminder history |
| ReminderLog | `assignmentId` | Assignment reminder tracking |
| ConflictOfInterest | `assignmentId` (unique) | COI per assignment |
| ConflictOfInterest | `userId` | User COI declarations |
| ConflictOfInterest | `roundId` | COI per round |
| ConflictOfInterest | `hasConflict` | Filter active conflicts |
| EvaluationSummary | `projectId, roundId` (unique) | One summary per project per round |
| EvaluationSummary | `roundId` | Summaries per round |
| ProjectStatusHistory | `projectId` | Status change timeline |
| ProjectStatusHistory | `createdAt` | Chronological ordering |
| MentorMessage | `projectId` | Messages per project |
| MentorMessage | `senderId` | Sent messages |
| MentorMessage | `recipientId` | Received messages |
| MentorMessage | `createdAt` | Chronological ordering |
### JSON Field Indexes

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

View File

@ -250,16 +250,29 @@ model User {
// Award overrides
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
// In-app notifications
notifications InAppNotification[] @relation("UserNotifications")
notificationSettingsUpdated NotificationEmailSetting[] @relation("NotificationSettingUpdater")
// Reminder logs
reminderLogs ReminderLog[]
// Conflict of interest
conflictsOfInterest ConflictOfInterest[]
coiReviews ConflictOfInterest[] @relation("COIReviewedBy")
// Evaluation summaries
generatedSummaries EvaluationSummary[] @relation("EvaluationSummaryGeneratedBy")
// Mentor messages
mentorMessages MentorMessage[] @relation("MentorMessageSender")
// NextAuth relations
accounts Account[]
sessions Session[]
@@index([email])
@@index([role])
@@index([status])
}
@ -377,6 +390,8 @@ model Round {
filteringJobs FilteringJob[]
assignmentJobs AssignmentJob[]
taggingJobs TaggingJob[]
reminderLogs ReminderLog[]
projectFiles ProjectFile[]
@@index([programId])
@@index([status])
@ -480,6 +495,9 @@ model Project {
awardVotes AwardVote[]
wonAwards SpecialAward[] @relation("AwardWinner")
projectTags ProjectTag[]
statusHistory ProjectStatusHistory[]
mentorMessages MentorMessage[]
evaluationSummaries EvaluationSummary[]
@@index([roundId])
@@index([status])
@ -494,6 +512,7 @@ model Project {
model ProjectFile {
id String @id @default(cuid())
projectId String
roundId String? // Which round this file was submitted for
// File info
fileType FileType
@ -505,13 +524,17 @@ model ProjectFile {
bucket String
objectKey String
isLate Boolean @default(false) // Uploaded after round deadline
createdAt DateTime @default(now())
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round? @relation(fields: [roundId], references: [id])
@@unique([bucket, objectKey])
@@index([projectId])
@@index([roundId])
@@index([fileType])
}
@ -543,6 +566,7 @@ model Assignment {
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
evaluation Evaluation?
conflictOfInterest ConflictOfInterest?
@@unique([userId, projectId, roundId])
@@index([userId])
@ -566,7 +590,9 @@ model Evaluation {
binaryDecision Boolean? // Yes/No for semi-finalist
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)
// Timestamps
@ -581,6 +607,7 @@ model Evaluation {
@@index([status])
@@index([submittedAt])
@@index([formId])
@@index([status, formId])
}
// =============================================================================
@ -609,6 +636,7 @@ model GracePeriod {
@@index([extendedUntil])
@@index([grantedById])
@@index([projectId])
@@index([roundId, userId, extendedUntil])
}
// =============================================================================
@ -660,6 +688,7 @@ model AuditLog {
@@index([action])
@@index([entityType, entityId])
@@index([timestamp])
@@index([entityType, entityId, timestamp])
}
// =============================================================================
@ -1212,7 +1241,7 @@ model SpecialAward {
// Winner
winnerProjectId String?
winnerOverridden Boolean @default(false)
winnerOverriddenBy String?
winnerOverriddenBy String? // FK to User who overrode the winner
sortOrder Int @default(0)
@ -1222,6 +1251,7 @@ model SpecialAward {
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
winnerProject Project? @relation("AwardWinner", fields: [winnerProjectId], references: [id], onDelete: SetNull)
overriddenByUser User? @relation("AwardOverriddenBy", fields: [winnerOverriddenBy], references: [id], onDelete: SetNull)
eligibilities AwardEligibility[]
jurors AwardJuror[]
votes AwardVote[]
@ -1291,3 +1321,109 @@ model AwardVote {
@@index([userId])
@@index([projectId])
}
// =============================================================================
// REMINDER LOG (Evaluation Deadline Reminders)
// =============================================================================
model ReminderLog {
id String @id @default(cuid())
roundId String
userId String
type String // "3_DAYS", "24H", "1H"
sentAt DateTime @default(now())
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([roundId, userId, type])
@@index([roundId])
}
// =============================================================================
// CONFLICT OF INTEREST
// =============================================================================
model ConflictOfInterest {
id String @id @default(cuid())
assignmentId String @unique
userId String
projectId String
roundId String
hasConflict Boolean @default(false)
conflictType String? // "financial", "personal", "organizational", "other"
description String? @db.Text
declaredAt DateTime @default(now())
// Admin review
reviewedById String?
reviewedAt DateTime?
reviewAction String? // "cleared", "reassigned", "noted"
// Relations
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
reviewedBy User? @relation("COIReviewedBy", fields: [reviewedById], references: [id])
@@index([userId])
@@index([roundId, hasConflict])
}
// =============================================================================
// AI EVALUATION SUMMARY
// =============================================================================
model EvaluationSummary {
id String @id @default(cuid())
projectId String
roundId String
summaryJson Json @db.JsonB
generatedAt DateTime @default(now())
generatedById String
model String
tokensUsed Int
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
generatedBy User @relation("EvaluationSummaryGeneratedBy", fields: [generatedById], references: [id])
@@unique([projectId, roundId])
@@index([roundId])
}
// =============================================================================
// PROJECT STATUS HISTORY
// =============================================================================
model ProjectStatusHistory {
id String @id @default(cuid())
projectId String
status ProjectStatus
changedAt DateTime @default(now())
changedBy String?
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@index([projectId, changedAt])
}
// =============================================================================
// MENTOR MESSAGES
// =============================================================================
model MentorMessage {
id String @id @default(cuid())
projectId String
senderId String
message String @db.Text
isRead Boolean @default(false)
createdAt DateTime @default(now())
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
sender User @relation("MentorMessageSender", fields: [senderId], references: [id])
@@index([projectId, createdAt])
}

View File

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

View File

@ -67,6 +67,8 @@ import {
AlertCircle,
Layers,
FolderOpen,
X,
AlertTriangle,
} from 'lucide-react'
import {
Select,
@ -75,6 +77,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
@ -346,6 +349,77 @@ export default function ProjectsPage() {
? Math.round((jobStatus.processedCount / jobStatus.totalProjects) * 100)
: 0
// Bulk selection state
const [selectedIds, setSelectedIds] = useState<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({
onSuccess: () => {
toast.success('Project deleted successfully')
@ -468,6 +542,15 @@ export default function ProjectsPage() {
<Table>
<TableHeader>
<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>Round</TableHead>
<TableHead>Files</TableHead>
@ -484,6 +567,16 @@ export default function ProjectsPage() {
key={project.id}
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
>
{filters.roundId && (
<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>
<Link
href={`/admin/projects/${project.id}`}
@ -519,7 +612,7 @@ export default function ProjectsPage() {
</p>
</div>
</TableCell>
<TableCell>{project.files?.length ?? 0}</TableCell>
<TableCell>{project._count?.files ?? 0}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
@ -577,14 +670,23 @@ export default function ProjectsPage() {
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{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
key={project.id}
href={`/admin/projects/${project.id}`}
className="block"
>
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<div className={`flex items-start gap-3 ${filters.roundId ? 'pl-8' : ''}`}>
<ProjectLogo
project={project}
size="md"
@ -627,6 +729,7 @@ export default function ProjectsPage() {
</CardContent>
</Card>
</Link>
</div>
))}
</div>
@ -641,6 +744,93 @@ export default function ProjectsPage() {
</>
) : 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 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<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>
</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 */}
<Card>

View File

@ -53,6 +53,7 @@ import {
RotateCcw,
Loader2,
ShieldCheck,
Download,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@ -109,6 +110,41 @@ export default function FilteringResultsPage({
const overrideResult = trpc.filtering.overrideResult.useMutation()
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
const exportResults = trpc.export.filteringResults.useQuery(
{ roundId },
{ enabled: false }
)
const handleExport = async () => {
const result = await exportResults.refetch()
if (result.data) {
const { data: rows, columns } = result.data
const csvContent = [
columns.join(','),
...rows.map((row) =>
columns
.map((col) => {
const value = row[col as keyof typeof row]
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`
}
return value ?? ''
})
.join(',')
),
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `filtering-results-${new Date().toISOString().split('T')[0]}.csv`
link.click()
URL.revokeObjectURL(url)
}
}
const toggleRow = (id: string) => {
const next = new Set(expandedRows)
if (next.has(id)) next.delete(id)
@ -166,6 +202,7 @@ export default function FilteringResultsPage({
</Button>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Filtering Results
@ -174,6 +211,19 @@ export default function FilteringResultsPage({
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>
{/* Outcome Filter Tabs */}
<div className="flex gap-2">

View File

@ -50,6 +50,7 @@ import {
AlertTriangle,
ListChecks,
ClipboardCheck,
Sparkles,
} from 'lucide-react'
import { toast } from 'sonner'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
@ -125,6 +126,22 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const startJob = trpc.filtering.startJob.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
// AI summary bulk generation
const bulkSummaries = trpc.evaluation.generateBulkSummaries.useMutation({
onSuccess: (data) => {
if (data.errors.length > 0) {
toast.warning(
`Generated ${data.generated} of ${data.total} summaries. ${data.errors.length} failed.`
)
} else {
toast.success(`Generated ${data.generated} AI summaries successfully`)
}
},
onError: (error) => {
toast.error(error.message || 'Failed to generate AI summaries')
},
})
// Set active job from latest job on load
useEffect(() => {
if (latestJob && (latestJob.status === 'RUNNING' || latestJob.status === 'PENDING')) {
@ -764,6 +781,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Jury Assignments
</Link>
</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>
</CardContent>

View File

@ -186,8 +186,8 @@ function ProgramRounds({ program }: { program: any }) {
<CardContent>
{rounds.length > 0 ? (
<div className="space-y-2">
{/* 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">
{/* Desktop: Table header */}
<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>Round</div>
<div>Status</div>
@ -207,7 +207,7 @@ function ProgramRounds({ program }: { program: any }) {
items={rounds.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
<div className="space-y-2 lg:space-y-1">
{rounds.map((round, index) => (
<SortableRoundRow
key={round.id}
@ -378,64 +378,7 @@ function SortableRoundRow({
)
}
return (
<div
ref={setNodeRef}
style={style}
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',
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
isReordering && !isDragging && 'opacity-50'
)}
>
{/* Order number with drag handle */}
<div className="flex items-center gap-1">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
disabled={isReordering}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<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>
const actionsMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
@ -502,7 +445,9 @@ function SortableRoundRow({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
const deleteDialog = (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
@ -528,7 +473,134 @@ function SortableRoundRow({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'rounded-lg border bg-card transition-all',
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
isReordering && !isDragging && 'opacity-50'
)}
>
{/* Desktop: Table row layout */}
<div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5">
{/* Order number with drag handle */}
<div className="flex items-center gap-1">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
disabled={isReordering}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<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>
{/* Mobile/Tablet: Card layout */}
<div className="lg:hidden p-4">
{/* Top row: drag handle, order, name, status badge, actions */}
<div className="flex items-start gap-3">
<div className="flex items-center gap-1 pt-0.5">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
disabled={isReordering}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<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>
<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>
{/* Details row */}
<div className="mt-3 ml-11 grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
<div>
<p className="text-xs text-muted-foreground">Voting Window</p>
<div className="mt-0.5">{getVotingWindow()}</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Projects</p>
<div className="flex items-center gap-1.5 mt-0.5">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{round._count?.projects || 0}</span>
</div>
</div>
<div>
<p className="text-xs text-muted-foreground">Reviewers</p>
<div className="flex items-center gap-1.5 mt-0.5">
<Users className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{round._count?.assignments || 0}</span>
</div>
</div>
</div>
</div>
{deleteDialog}
</div>
)
}
@ -548,7 +620,8 @@ function RoundsListSkeleton() {
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Desktop skeleton */}
<div className="hidden lg:block space-y-3">
{[1, 2, 3].map((j) => (
<div key={j} className="flex justify-between items-center py-2">
<Skeleton className="h-4 w-40" />
@ -560,6 +633,26 @@ function RoundsListSkeleton() {
</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>
</Card>
))}

View File

@ -4,6 +4,7 @@ import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/shared/logo'
import { AlertCircle } from 'lucide-react'
const errorMessages: Record<string, string> = {
@ -21,16 +22,22 @@ export default function AuthErrorPage() {
return (
<Card className="w-full max-w-md">
<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" />
</div>
<CardTitle className="text-xl">Authentication Error</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-center">
<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>
<Link href="/login">Try again</Link>
<Link href="/login">Return to Login</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/">Home</Link>
</Button>
</div>
</CardContent>

View File

@ -25,6 +25,7 @@ import {
ArrowRight,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import { CountdownTimer } from '@/components/shared/countdown-timer'
async function JuryDashboardContent() {
const session = await auth()
@ -105,6 +106,27 @@ async function JuryDashboardContent() {
{} as Record<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)
const now = new Date()
const activeRounds = Object.values(assignmentsByRound).filter(
@ -221,9 +243,15 @@ async function JuryDashboardContent() {
</div>
{round.votingEndAt && (
<p className="text-xs text-muted-foreground">
Deadline: {formatDateOnly(round.votingEndAt)}
</p>
<div className="flex items-center gap-2 flex-wrap">
<CountdownTimer
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">

View File

@ -8,7 +8,7 @@ export const dynamic = 'force-dynamic'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
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 { isFuture, isPast } from 'date-fns'
@ -21,9 +21,20 @@ interface Criterion {
id: string
label: string
description?: string
scale: number
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
}
async function EvaluateContent({ projectId }: { projectId: string }) {
@ -133,6 +144,14 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
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
const evaluationForm = round.evaluationForms[0]
if (!evaluationForm) {
@ -247,8 +266,8 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
</Card>
)}
{/* Evaluation Form */}
<EvaluationForm
{/* Evaluation Form with COI Gate */}
<EvaluationFormWithCOI
assignmentId={assignment.id}
evaluationId={evaluation?.id || null}
projectTitle={project.title}
@ -258,7 +277,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
? {
criterionScoresJson: evaluation.criterionScoresJson as Record<
string,
number
number | string | boolean
> | null,
globalScore: evaluation.globalScore,
binaryDecision: evaluation.binaryDecision,
@ -269,6 +288,7 @@ async function EvaluateContent({ projectId }: { projectId: string }) {
}
isVotingOpen={effectiveVotingOpen}
deadline={round.votingEndAt}
coiStatus={coiStatus}
/>
</div>
)

View File

@ -16,6 +16,7 @@ import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import { FileViewer } from '@/components/shared/file-viewer'
import { MentorChat } from '@/components/shared/mentor-chat'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import {
ArrowLeft,
@ -30,6 +31,7 @@ import {
Calendar,
FileText,
ExternalLink,
MessageSquare,
} from 'lucide-react'
import { formatDateOnly, getInitials } from '@/lib/utils'
@ -52,6 +54,17 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
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) {
return <ProjectDetailSkeleton />
}
@ -363,6 +376,30 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)}
</CardContent>
</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>
)
}

View File

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

View File

@ -1,7 +1,7 @@
import { Suspense } from 'react'
import { prisma } from '@/lib/prisma'
'use client'
export const dynamic = 'force-dynamic'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
@ -20,80 +20,72 @@ import {
TableHeader,
TableRow,
} 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 {
ScoreDistributionChart,
EvaluationTimelineChart,
StatusBreakdownChart,
JurorWorkloadChart,
ProjectRankingsChart,
CriteriaScoresChart,
} from '@/components/charts'
async function ReportsContent() {
// Get rounds with evaluation stats
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' },
})
function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
// Calculate completion stats for each round
const roundStats = rounds.map((round) => {
const totalAssignments = round._count.assignments
const completedEvaluations = round.assignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const completionRate =
totalAssignments > 0
? Math.round((completedEvaluations / totalAssignments) * 100)
: 0
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({
...r,
programName: `${p.year} Edition`,
}))
) || []
return {
...round,
totalAssignments,
completedEvaluations,
completionRate,
}
})
// Calculate totals
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
const { data: overviewStats, isLoading: statsLoading } =
trpc.analytics.getOverviewStats.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
if (rounds.length === 0) {
if (isLoading) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileSpreadsheet className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No data to report</p>
<p className="text-sm text-muted-foreground">
Reports will appear here once rounds are created
</p>
<div className="space-y-6">
<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>
</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 (
<div className="space-y-6">
{/* Quick Stats */}
@ -106,7 +98,7 @@ async function ReportsContent() {
<CardContent>
<div className="text-2xl font-bold">{rounds.length}</div>
<p className="text-xs text-muted-foreground">
{rounds.filter((r) => r.status === 'ACTIVE').length} active
{activeRounds} active
</p>
</CardContent>
</Card>
@ -122,14 +114,71 @@ async function ReportsContent() {
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Rounds</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeRounds}</div>
<p className="text-xs text-muted-foreground">Currently active</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Programs</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalPrograms}</div>
<p className="text-xs text-muted-foreground">Total programs</p>
</CardContent>
</Card>
</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">{totalAssignments}</div>
<p className="text-xs text-muted-foreground">Total assignments</p>
<div className="text-2xl font-bold">{overviewStats.assignmentCount}</div>
<p className="text-xs text-muted-foreground">
{overviewStats.jurorCount} jurors
</p>
</CardContent>
</Card>
@ -139,11 +188,26 @@ async function ReportsContent() {
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalEvaluations}</div>
<p className="text-xs text-muted-foreground">Completed</p>
<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 */}
<Card className="hidden md:block">
@ -158,12 +222,11 @@ async function ReportsContent() {
<TableHead>Round</TableHead>
<TableHead>Program</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{roundStats.map((round) => (
{rounds.map((round) => (
<TableRow key={round.id}>
<TableCell>
<div>
@ -175,21 +238,8 @@ async function ReportsContent() {
)}
</div>
</TableCell>
<TableCell>{round.program.name}</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>{round.programName}</TableCell>
<TableCell>{round._count?.projects || '-'}</TableCell>
<TableCell>
<Badge
variant={
@ -213,7 +263,7 @@ async function ReportsContent() {
{/* Rounds Cards - Mobile */}
<div className="space-y-4 md:hidden">
<h2 className="text-lg font-semibold">Round Reports</h2>
{roundStats.map((round) => (
{rounds.map((round) => (
<Card key={round.id}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
@ -230,24 +280,14 @@ async function ReportsContent() {
{round.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{round.program.name}</p>
<p className="text-sm text-muted-foreground">{round.programName}</p>
{round.votingEndAt && (
<p className="text-xs text-muted-foreground">
Ends: {formatDateOnly(round.votingEndAt)}
</p>
)}
<div className="flex items-center justify-between text-sm">
<span>{round._count.projects} 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 className="text-sm">
<span>{round._count?.projects || 0} projects</span>
</div>
</CardContent>
</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 (
<div className="space-y-6">
<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>
))}
{/* Row 1: Score Distribution & Status Breakdown */}
<div className="grid gap-6 lg:grid-cols-2">
{scoreLoading ? (
<Skeleton className="h-[350px]" />
) : scoreDistribution ? (
<ScoreDistributionChart
data={scoreDistribution.distribution}
averageScore={scoreDistribution.averageScore}
totalScores={scoreDistribution.totalScores}
/>
) : null}
{statusLoading ? (
<Skeleton className="h-[350px]" />
) : statusBreakdown ? (
<StatusBreakdownChart data={statusBreakdown} />
) : null}
</div>
{/* Row 2: Evaluation Timeline */}
{timelineLoading ? (
<Skeleton className="h-[350px]" />
) : timeline?.length ? (
<EvaluationTimelineChart data={timeline} />
) : (
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No evaluation data available yet
</p>
</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>
)
}
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 (
<div className="space-y-6">
{/* Header */}
@ -301,10 +437,62 @@ export default function ObserverReportsPage() {
</p>
</div>
{/* Content */}
<Suspense fallback={<ReportsSkeleton />}>
<ReportsContent />
</Suspense>
{/* Round Selector */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<label className="text-sm font-medium">Select Round:</label>
{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>
)
}

View File

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

View File

@ -1,5 +1,6 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
@ -16,17 +17,21 @@ import {
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
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 { MentorChat } from '@/components/shared/mentor-chat'
import {
ArrowLeft,
FileText,
Clock,
AlertCircle,
AlertTriangle,
Download,
Video,
File,
Users,
Crown,
MessageSquare,
} from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
@ -64,12 +69,25 @@ export function SubmissionDetailClient() {
const params = useParams()
const { data: session } = useSession()
const projectId = params.id as string
const [activeTab, setActiveTab] = useState('details')
const { data: statusData, isLoading, error } = trpc.applicant.getSubmissionStatus.useQuery(
{ projectId },
{ 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) {
return (
<div className="max-w-4xl mx-auto space-y-6">
@ -148,6 +166,18 @@ export function SubmissionDetailClient() {
</Alert>
)}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList>
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="mentor" className="gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
Mentor
</TabsTrigger>
</TabsList>
{/* 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">
@ -184,49 +214,6 @@ export function SubmissionDetailClient() {
</CardContent>
</Card>
{/* Files */}
<Card>
<CardHeader>
<CardTitle>Uploaded Documents</CardTitle>
<CardDescription>
Documents submitted with your application
</CardDescription>
</CardHeader>
<CardContent>
{project.files.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No documents uploaded
</p>
) : (
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
return (
<div
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<p className="font-medium">{file.fileName}</p>
<p className="text-sm text-muted-foreground">
{fileTypeLabels[file.fileType] || file.fileType}
</p>
</div>
</div>
<Button variant="ghost" size="sm" disabled>
<Download className="h-4 w-4" />
</Button>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Metadata */}
{project.metadataJson && Object.keys(project.metadataJson as Record<string, unknown>).length > 0 && (
<Card>
@ -330,6 +317,88 @@ export function SubmissionDetailClient() {
)}
</div>
</div>
</TabsContent>
{/* Documents Tab */}
<TabsContent value="documents">
<Card>
<CardHeader>
<CardTitle>Uploaded Documents</CardTitle>
<CardDescription>
Documents submitted with your application
</CardDescription>
</CardHeader>
<CardContent>
{project.files.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No documents uploaded
</p>
) : (
<div className="space-y-2">
{project.files.map((file) => {
const Icon = fileTypeIcons[file.fileType] || File
const fileRecord = file as typeof file & { isLate?: boolean; roundId?: string | null }
return (
<div
key={file.id}
className="flex items-center justify-between p-3 rounded-lg border"
>
<div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-muted-foreground" />
<div>
<div className="flex items-center gap-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">
{fileTypeLabels[file.fileType] || file.fileType}
</p>
</div>
</div>
<Button variant="ghost" size="sm" disabled>
<Download className="h-4 w-4" />
</Button>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Mentor Tab */}
<TabsContent value="mentor">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
Mentor Communication
</CardTitle>
<CardDescription>
Chat with your assigned mentor
</CardDescription>
</CardHeader>
<CardContent>
<MentorChat
messages={mentorMessages || []}
currentUserId={session?.user?.id || ''}
onSendMessage={async (message) => {
await sendMessage.mutateAsync({ projectId, message })
}}
isLoading={messagesLoading}
isSending={sendMessage.isPending}
/>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
@ -73,6 +73,7 @@ export default function ProfileSettingsPage() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
// Populate form when user data loads
useEffect(() => {
if (user && !profileLoaded) {
setName(user.name || '')
const meta = (user.metadataJson as Record<string, unknown>) || {}
@ -82,6 +83,7 @@ export default function ProfileSettingsPage() {
setExpertiseTags(user.expertiseTags || [])
setProfileLoaded(true)
}
}, [user, profileLoaded])
const handleSaveProfile = async () => {
try {
@ -237,19 +239,6 @@ export default function ProfileSettingsPage() {
/>
</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>
</Card>
@ -283,19 +272,6 @@ export default function ProfileSettingsPage() {
</Select>
</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>
</Card>
@ -318,21 +294,24 @@ export default function ProfileSettingsPage() {
maxTags={15}
/>
</CardContent>
</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 Expertise
Save All Changes
</Button>
</div>
</CardContent>
</Card>
{/* Change Password */}
<Card>

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 nodemailer from 'nodemailer'
import { checkRateLimit } from '@/lib/rate-limit'
import { auth } from '@/lib/auth'
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
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> {
// 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 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()
// 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}`)) {
return NextResponse.json(
{ error: `Email must be an @${MAIL_DOMAIN} address.` },

View File

@ -1,12 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
import { checkRateLimit } from '@/lib/rate-limit'
import { auth } from '@/lib/auth'
const MAIL_DOMAIN = process.env.POSTE_MAIL_DOMAIN || 'monaco-opc.com'
const SMTP_HOST = process.env.SMTP_HOST || 'localhost'
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587')
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 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()
// 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}`)) {
return NextResponse.json(
{ error: `Email must be an @${MAIL_DOMAIN} address.` },

View File

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

View File

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

View File

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

View File

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

View File

@ -1,18 +1,53 @@
import Link from 'next/link'
import Image from 'next/image'
import { Button } from '@/components/ui/button'
import { FileQuestion } from 'lucide-react'
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 text-center">
<FileQuestion className="h-16 w-16 text-muted-foreground/50" />
<h1 className="text-2xl font-semibold">Page Not Found</h1>
<p className="text-muted-foreground">
<div className="flex min-h-screen flex-col bg-background">
{/* Header */}
<header className="border-b border-border bg-white">
<div className="container-app flex h-16 items-center">
<Link href="/">
<Image
src="/images/MOPC-blue-long.png"
alt="MOPC - Monaco Ocean Protection Challenge"
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>
<Button asChild>
<Link href="/">Go Home</Link>
<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>
)
}

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,
Check,
X,
Type,
ToggleLeft,
Hash,
Heading,
ThumbsUp,
ThumbsDown,
} from 'lucide-react'
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 {
id: string
label: string
description?: string
scale: number // 5 or 10
type?: CriterionType // defaults to 'numeric'
// Numeric fields
scale?: number // 5 or 10
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 {
@ -67,17 +93,34 @@ function generateId(): string {
return `criterion-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function createDefaultCriterion(): Criterion {
return {
function createDefaultCriterion(type: CriterionType = 'numeric'): Criterion {
const base: Criterion = {
id: generateId(),
label: '',
description: '',
scale: 5,
weight: 1,
required: true,
type,
}
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({
initialCriteria = [],
onChange,
@ -97,8 +140,8 @@ export function EvaluationFormBuilder({
)
// Add new criterion
const addCriterion = useCallback(() => {
const newCriterion = createDefaultCriterion()
const addCriterion = useCallback((type: CriterionType = 'numeric') => {
const newCriterion = createDefaultCriterion(type)
const newCriteria = [...criteria, newCriterion]
updateCriteria(newCriteria)
setEditingId(newCriterion.id)
@ -190,13 +233,24 @@ export function EvaluationFormBuilder({
{isEditing && editDraft ? (
// Edit mode
<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">
<Label htmlFor={`label-${criterion.id}`}>Label *</Label>
<Input
id={`label-${criterion.id}`}
value={editDraft.label}
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}
autoFocus
/>
@ -217,11 +271,13 @@ export function EvaluationFormBuilder({
/>
</div>
{/* Type-specific fields */}
{(editDraft.type || 'numeric') === 'numeric' && (
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor={`scale-${criterion.id}`}>Scale</Label>
<Select
value={String(editDraft.scale)}
value={String(editDraft.scale ?? 5)}
onValueChange={(v) => updateDraft({ scale: parseInt(v) })}
disabled={disabled}
>
@ -266,7 +322,7 @@ export function EvaluationFormBuilder({
<Label>Required</Label>
<div className="flex items-center h-10">
<Switch
checked={editDraft.required}
checked={editDraft.required ?? true}
onCheckedChange={(checked) =>
updateDraft({ required: checked })
}
@ -275,6 +331,186 @@ export function EvaluationFormBuilder({
</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}
/>
</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>
)}
{(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 */}
<div className="flex justify-end gap-2 pt-2">
@ -310,22 +546,37 @@ export function EvaluationFormBuilder({
{/* Criterion info */}
<div className="flex-1 min-w-0">
<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)'}
</span>
<Badge variant="secondary" className="shrink-0 text-xs">
1-{criterion.scale}
{(() => {
const type = criterion.type || 'numeric'
const TypeIcon = CRITERION_TYPE_OPTIONS.find((t) => t.value === type)?.icon ?? Hash
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.weight && criterion.weight !== 1 && (criterion.type || 'numeric') === 'numeric' && (
<Badge variant="outline" className="shrink-0 text-xs">
{criterion.weight}x
</Badge>
)}
{criterion.required && (
{criterion.required && (criterion.type || 'numeric') !== 'section_header' && (
<Badge variant="default" className="shrink-0 text-xs">
Required
</Badge>
)}
{criterion.condition && (
<Badge variant="outline" className="shrink-0 text-xs text-amber-600 border-amber-300">
Conditional
</Badge>
)}
</div>
{criterion.description && (
<p className="text-sm text-muted-foreground truncate mt-0.5">
@ -418,17 +669,20 @@ export function EvaluationFormBuilder({
{/* Actions */}
{!disabled && (
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
{CRITERION_TYPE_OPTIONS.map(({ value, label, icon: Icon }) => (
<Button
key={value}
type="button"
variant="outline"
size="sm"
onClick={addCriterion}
onClick={() => addCriterion(value)}
disabled={editingId !== null}
>
<Plus className="mr-1 h-4 w-4" />
Add Criterion
<Icon className="mr-1 h-4 w-4" />
{label}
</Button>
))}
{criteria.length > 0 && (
<PreviewDialog criteria={criteria} />
@ -458,7 +712,21 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
</DialogHeader>
<div className="space-y-6 py-4">
{criteria.map((criterion) => (
{criteria.map((criterion) => {
const type = criterion.type || 'numeric'
if (type === 'section_header') {
return (
<div key={criterion.id} className="border-b pb-2 pt-4">
<h3 className="font-semibold text-lg">{criterion.label}</h3>
{criterion.description && (
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
)}
</div>
)
}
return (
<Card key={criterion.id}>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2">
@ -474,6 +742,7 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
)}
</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>
@ -484,18 +753,17 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
/>
</div>
<span className="text-xs text-muted-foreground w-4">
{criterion.scale}
{criterion.scale ?? 5}
</span>
</div>
<div className="flex gap-1 flex-wrap">
{Array.from({ length: criterion.scale }, (_, i) => i + 1).map(
{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 / 2)
num <= Math.ceil((criterion.scale ?? 5) / 2)
? 'bg-primary/20 text-primary'
: 'bg-muted'
)}
@ -506,9 +774,32 @@ function PreviewDialog({ criteria }: { criteria: Criterion[] }) {
)}
</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 && (
<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'
import { useState, useEffect, useCallback, useTransition } from 'react'
import { useState, useEffect, useCallback, useTransition, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, Controller } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
@ -14,6 +14,7 @@ import { Textarea } from '@/components/ui/textarea'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
AlertDialog,
AlertDialogAction,
@ -38,15 +39,36 @@ import {
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { CountdownTimer } from '@/components/shared/countdown-timer'
// 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 {
id: string
label: 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
required?: boolean
// Text
maxLength?: number
placeholder?: string
// Boolean
trueLabel?: string
falseLabel?: string
// Conditional visibility
condition?: CriterionCondition
// Section grouping
sectionId?: string
}
interface EvaluationFormProps {
@ -55,7 +77,7 @@ interface EvaluationFormProps {
projectTitle: string
criteria: Criterion[]
initialData?: {
criterionScoresJson: Record<string, number> | null
criterionScoresJson: Record<string, number | string | boolean> | null
globalScore: number | null
binaryDecision: boolean | null
feedbackText: string | null
@ -65,15 +87,52 @@ interface EvaluationFormProps {
deadline?: Date | null
}
const createEvaluationSchema = (criteria: Criterion[]) =>
z.object({
criterionScores: z.record(z.number()),
const createEvaluationSchema = (criteria: Criterion[]) => {
const criterionFields: Record<string, z.ZodTypeAny> = {}
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),
binaryDecision: z.boolean(),
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({
assignmentId,
@ -89,10 +148,41 @@ export function EvaluationForm({
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
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
const defaultCriterionScores: Record<string, number> = {}
criteria.forEach((c) => {
defaultCriterionScores[c.id] = initialData?.criterionScoresJson?.[c.id] ?? Math.ceil(c.scale / 2)
const defaultCriterionScores: Record<string, number | string | boolean> = {}
scorableCriteria.forEach((c) => {
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>({
@ -109,6 +199,46 @@ export function EvaluationForm({
const { watch, handleSubmit, control, formState } = form
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
const startEvaluation = trpc.evaluation.start.useMutation()
const autosave = trpc.evaluation.autosave.useMutation()
@ -206,10 +336,20 @@ export function EvaluationForm({
{/* 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="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">
{projectTitle}
</h2>
{!isReadOnly && deadline && (
<CountdownTimer deadline={new Date(deadline)} />
)}
{!isReadOnly && (
<ProgressIndicator
percentage={progress.percentage}
criteriaDone={progress.criteriaDone}
criteriaTotal={progress.criteriaTotal}
/>
)}
<AutosaveIndicator status={autosaveStatus} lastSaved={lastSaved} />
</div>
@ -273,14 +413,61 @@ export function EvaluationForm({
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{criteria.map((criterion) => (
<CriterionField
{criteria.map((criterion) => {
const type = criterion.type || 'numeric'
// Evaluate conditional visibility
if (criterion.condition) {
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>
</Card>
)}
@ -309,7 +496,10 @@ export function EvaluationForm({
max={10}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
onValueChange={(v) => {
field.onChange(v[0])
setGlobalScoreTouched(true)
}}
disabled={isReadOnly}
className="py-4"
/>
@ -318,7 +508,12 @@ export function EvaluationForm({
<button
key={num}
type="button"
onClick={() => !isReadOnly && field.onChange(num)}
onClick={() => {
if (!isReadOnly) {
field.onChange(num)
setGlobalScoreTouched(true)
}
}}
disabled={isReadOnly}
className={cn(
'w-8 h-8 rounded-full text-sm font-medium transition-colors',
@ -359,7 +554,12 @@ export function EvaluationForm({
'flex-1 h-20',
field.value && 'bg-green-600 hover:bg-green-700'
)}
onClick={() => !isReadOnly && field.onChange(true)}
onClick={() => {
if (!isReadOnly) {
field.onChange(true)
setDecisionTouched(true)
}
}}
disabled={isReadOnly}
>
<ThumbsUp className="mr-2 h-6 w-6" />
@ -372,7 +572,12 @@ export function EvaluationForm({
'flex-1 h-20',
!field.value && 'bg-red-600 hover:bg-red-700'
)}
onClick={() => !isReadOnly && field.onChange(false)}
onClick={() => {
if (!isReadOnly) {
field.onChange(false)
setDecisionTouched(true)
}
}}
disabled={isReadOnly}
>
<ThumbsDown className="mr-2 h-6 w-6" />
@ -484,16 +689,31 @@ export function EvaluationForm({
)
}
// Criterion field component
function CriterionField({
// Section header component (no input)
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,
control,
disabled,
onTouch,
}: {
criterion: Criterion
control: any
disabled: boolean
onTouch: (criterionId: string) => void
}) {
const scale = criterion.scale ?? 5
return (
<Controller
name={`criterionScores.${criterion.id}`}
@ -510,7 +730,7 @@ function CriterionField({
)}
</div>
<Badge variant="secondary" className="shrink-0">
{field.value}/{criterion.scale}
{field.value}/{scale}
</Badge>
</div>
@ -518,23 +738,31 @@ function CriterionField({
<span className="text-xs text-muted-foreground w-4">1</span>
<Slider
min={1}
max={criterion.scale}
max={scale}
step={1}
value={[field.value]}
onValueChange={(v) => field.onChange(v[0])}
value={[typeof field.value === 'number' ? field.value : Math.ceil(scale / 2)]}
onValueChange={(v) => {
field.onChange(v[0])
onTouch(criterion.id)
}}
disabled={disabled}
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>
{/* Visual rating buttons */}
<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
key={num}
type="button"
onClick={() => !disabled && field.onChange(num)}
onClick={() => {
if (!disabled) {
field.onChange(num)
onTouch(criterion.id)
}
}}
disabled={disabled}
className={cn(
'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
function AutosaveIndicator({
status,

View File

@ -244,26 +244,26 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
<div className="border-t p-3">
<DropdownMenu>
<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 */}
<div className="relative shrink-0">
<UserAvatar user={user} avatarUrl={avatarUrl} size="md" />
{/* 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>
{/* User info */}
<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'}
</p>
<p className="truncate text-xs text-slate-500 dark:text-slate-400">
<p className="truncate text-xs text-muted-foreground">
{roleLabel}
</p>
</div>
{/* 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>
</DropdownMenuTrigger>

View File

@ -1,183 +1,37 @@
'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 { BookOpen, ClipboardList, Home, LogOut, Menu, Settings, User, X } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
import { BookOpen, ClipboardList, Home } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface JuryNavProps {
user: {
name?: string | null
email?: string | null
}
}
const navigation = [
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/jury' as const,
href: '/jury',
icon: Home,
},
{
name: 'My Assignments',
href: '/jury/assignments' as const,
href: '/jury/assignments',
icon: ClipboardList,
},
{
name: 'Learning Hub',
href: '/jury/learning' as const,
href: '/jury/learning',
icon: BookOpen,
},
]
interface JuryNavProps {
user: RoleNavUser
}
export function JuryNav({ user }: JuryNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
return (
<>
{/* Desktop header */}
<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="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>
</>
<RoleNav
navigation={navigation}
roleName="Jury"
user={user}
basePath="/jury"
/>
)
}

View File

@ -1,183 +1,37 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
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'
import { BookOpen, Home, Users } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface MentorNavProps {
user: {
name?: string | null
email?: string | null
}
}
const navigation: { name: string; href: Route; icon: typeof Home }[] = [
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/mentor' as Route,
href: '/mentor',
icon: Home,
},
{
name: 'My Mentees',
href: '/mentor/projects' as Route,
href: '/mentor/projects',
icon: Users,
},
{
name: 'Resources',
href: '/mentor/resources' as Route,
href: '/mentor/resources',
icon: BookOpen,
},
]
interface MentorNavProps {
user: RoleNavUser
}
export function MentorNav({ user }: MentorNavProps) {
const pathname = usePathname()
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
return (
<>
{/* Desktop header */}
<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="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>
</>
<RoleNav
navigation={navigation}
roleName="Mentor"
user={user}
basePath="/mentor"
/>
)
}

View File

@ -1,169 +1,32 @@
'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 { Home, BarChart3, Menu, X, LogOut, Eye, Settings } from 'lucide-react'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
import { BarChart3, Home } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
interface ObserverNavProps {
user: {
name?: string | null
email?: string | null
}
}
const navigation = [
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/observer' as const,
href: '/observer',
icon: Home,
},
{
name: 'Reports',
href: '/observer/reports' as const,
href: '/observer/reports',
icon: BarChart3,
},
]
interface ObserverNavProps {
user: RoleNavUser
}
export function ObserverNav({ user }: ObserverNavProps) {
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 flex h-16 items-center justify-between">
{/* Logo */}
<Logo showText textSuffix="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>
<RoleNav
navigation={navigation}
roleName="Observer"
user={user}
basePath="/observer"
/>
)
}

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 Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { cn, formatRelativeTime } from '@/lib/utils'
import { Button } from '@/components/ui/button'
@ -207,6 +208,18 @@ function NotificationItem({
export function NotificationBell() {
const [filter, setFilter] = useState<'all' | 'unread'>('all')
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(
undefined,
@ -277,7 +290,7 @@ export function NotificationBell() {
</Button>
)}
<Button variant="ghost" size="icon" asChild>
<Link href={'/admin/settings' as Route}>
<Link href={`${pathPrefix}/settings` as Route}>
<Settings className="h-4 w-4" />
<span className="sr-only">Notification settings</span>
</Link>
@ -342,7 +355,7 @@ export function NotificationBell() {
{notifications.length > 0 && (
<div className="p-2 border-t bg-muted/30">
<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>
</div>
)}

View File

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

View File

@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
<div
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...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 {
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({
endPoint: url.hostname,
port: url.port ? parseInt(url.port) : (url.protocol === 'https:' ? 443 : 80),
useSSL: url.protocol === 'https:',
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
accessKey: accessKey || 'minioadmin',
secretKey: secretKey || 'minioadmin',
})
}
@ -109,12 +115,3 @@ export function generateObjectKey(
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']
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 path from 'path'
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'
/**
@ -31,7 +37,7 @@ export class LocalStorageProvider implements StorageProvider {
): string {
const expiresAt = Math.floor(Date.now() / 1000) + expirySeconds
const payload = `${action}:${key}:${expiresAt}`
const signature = createHmac('sha256', SECRET_KEY)
const signature = createHmac('sha256', getSecretKey())
.update(payload)
.digest('hex')
@ -55,17 +61,29 @@ export class LocalStorageProvider implements StorageProvider {
signature: string
): boolean {
const payload = `${action}:${key}:${expiresAt}`
const expectedSignature = createHmac('sha256', SECRET_KEY)
const expectedSignature = createHmac('sha256', getSecretKey())
.update(payload)
.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 {
// Sanitize key to prevent path traversal
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> {

View File

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

View File

@ -2,6 +2,8 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
// Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
@ -205,8 +207,8 @@ export const applicantRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Project',
@ -214,7 +216,6 @@ export const applicantRouter = router({
detailsJson: { title: input.title, source: 'applicant_portal' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project
@ -231,6 +232,7 @@ export const applicantRouter = router({
fileName: z.string(),
mimeType: z.string(),
fileType: z.enum(['EXEC_SUMMARY', 'BUSINESS_PLAN', 'VIDEO_PITCH', 'PRESENTATION', 'SUPPORTING_DOC', 'OTHER']),
roundId: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
@ -248,6 +250,9 @@ export const applicantRouter = router({
id: input.projectId,
submittedByUserId: ctx.user.id,
},
include: {
round: { select: { id: true, votingStartAt: true, settingsJson: true } },
},
})
if (!project) {
@ -257,8 +262,38 @@ export const applicantRouter = router({
})
}
// Can't upload if already submitted
if (project.submittedAt) {
// Check round upload deadline policy if roundId provided
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({
code: 'BAD_REQUEST',
message: 'Cannot modify a submitted project',
@ -275,6 +310,8 @@ export const applicantRouter = router({
url,
bucket: SUBMISSIONS_BUCKET,
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']),
bucket: z.string(),
objectKey: z.string(),
roundId: z.string().optional(),
isLate: z.boolean().optional(),
})
)
.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({
where: {
projectId,
fileType: input.fileType,
...(roundId ? { roundId } : {}),
},
})
@ -332,6 +372,8 @@ export const applicantRouter = router({
data: {
projectId,
...fileData,
roundId: roundId || null,
isLate: isLate || false,
},
})
@ -380,6 +422,48 @@ export const applicantRouter = router({
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
*/
@ -412,6 +496,9 @@ export const applicantRouter = router({
},
},
},
wonAwards: {
select: { id: true, name: true },
},
},
})
@ -425,39 +512,88 @@ export const applicantRouter = router({
// Get the project status
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 = [
{
status: 'CREATED',
label: 'Application Started',
date: project.createdAt,
completed: true,
isTerminal: false,
},
{
status: 'SUBMITTED',
label: 'Application Submitted',
date: project.submittedAt,
completed: !!project.submittedAt,
date: project.submittedAt || statusDateMap.get('SUBMITTED') || null,
completed: !!project.submittedAt || statusDateMap.has('SUBMITTED'),
isTerminal: false,
},
{
status: 'UNDER_REVIEW',
label: 'Under Review',
date: currentStatus === 'SUBMITTED' && project.submittedAt ? project.submittedAt : null,
completed: ['UNDER_REVIEW', 'SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
date: statusDateMap.get('ELIGIBLE') || statusDateMap.get('ASSIGNED') ||
(currentStatus !== 'SUBMITTED' && project.submittedAt ? project.submittedAt : null),
completed: ['ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(currentStatus),
isTerminal: false,
},
]
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: null, // Would need status change tracking
completed: ['SEMIFINALIST', 'FINALIST', 'WINNER'].includes(currentStatus),
date: statusDateMap.get('SEMIFINALIST') || null,
completed: ['SEMIFINALIST', 'FINALIST'].includes(currentStatus) || hasWonAward,
isTerminal: false,
},
{
status: 'FINALIST',
label: 'Finalist',
date: null,
completed: ['FINALIST', 'WINNER'].includes(currentStatus),
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 {
project,
@ -714,4 +850,130 @@ export const applicantRouter = router({
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,
NotificationTypes,
} from '../services/in-app-notification'
import { checkRateLimit } from '@/lib/rate-limit'
import { logAudit } from '@/server/utils/audit'
// Zod schemas for the application form
const teamMemberSchema = z.object({
@ -153,6 +155,16 @@ export const applicationRouter = router({
})
)
.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
// Verify round exists and is open
@ -288,8 +300,8 @@ export const applicationRouter = router({
}
// Create audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: user.id,
action: 'CREATE',
entityType: 'Project',
@ -301,7 +313,6 @@ export const applicationRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Notify applicant of successful submission
@ -351,6 +362,16 @@ export const applicationRouter = router({
})
)
.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({
where: {
roundId: input.roundId,

View File

@ -15,6 +15,7 @@ import {
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
// Background job execution function
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
@ -355,8 +356,8 @@ export const assignmentRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Assignment',
@ -364,7 +365,6 @@ export const assignmentRouter = router({
detailsJson: input,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Send notification to the assigned jury member
@ -434,15 +434,14 @@ export const assignmentRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'Assignment',
detailsJson: { count: result.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Send notifications to assigned jury members (grouped by user)
@ -472,10 +471,19 @@ export const assignmentRouter = router({
})
: 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)) {
await createNotification({
userId,
const existing = usersByProjectCount.get(projectCount) || []
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,
title: `${projectCount} Projects Assigned`,
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,8 +516,8 @@ export const assignmentRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Assignment',
@ -516,7 +528,6 @@ export const assignmentRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return assignment
@ -533,6 +544,7 @@ export const assignmentRouter = router({
completedAssignments,
assignmentsByUser,
projectCoverage,
round,
] = await Promise.all([
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({
@ -551,12 +563,11 @@ export const assignmentRouter = router({
_count: { select: { assignments: true } },
},
}),
])
const round = await ctx.prisma.round.findUniqueOrThrow({
ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { requiredReviews: true },
})
}),
])
const projectsWithFullCoverage = projectCoverage.filter(
(p) => p._count.assignments >= round.requiredReviews
@ -845,8 +856,8 @@ export const assignmentRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
@ -857,7 +868,6 @@ export const assignmentRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Send notifications to assigned jury members
@ -884,9 +894,19 @@ export const assignmentRouter = router({
})
: 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)) {
await createNotification({
userId,
const existing = usersByProjectCount.get(projectCount) || []
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,
title: `${projectCount} Projects Assigned`,
message: `You have been assigned ${projectCount} project${projectCount > 1 ? 's' : ''} to evaluate for ${round?.name || 'this round'}.`,
@ -934,8 +954,8 @@ export const assignmentRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
@ -945,7 +965,6 @@ export const assignmentRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Send notifications to assigned jury members
@ -972,9 +991,19 @@ export const assignmentRouter = router({
})
: 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)) {
await createNotification({
userId,
const existing = usersByProjectCount.get(projectCount) || []
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,
title: `${projectCount} Projects Assigned`,
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)
*/
getAIAssignmentJobStatus: protectedProcedure
getAIAssignmentJobStatus: adminProcedure
.input(z.object({ jobId: z.string() }))
.query(async ({ ctx, input }) => {
const job = await ctx.prisma.assignmentJob.findUniqueOrThrow({

View File

@ -1,14 +1,43 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure } from '../trpc'
import { generateAvatarKey, type StorageProviderType } from '@/lib/storage'
import {
getStorageProviderWithType,
createStorageProvider,
generateAvatarKey,
getContentType,
isValidImageType,
type StorageProviderType,
} from '@/lib/storage'
getImageUploadUrl,
confirmImageUpload,
getImageUrl,
deleteImage,
type ImageUploadConfig,
} from '../utils/image-upload'
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({
/**
@ -22,23 +51,12 @@ export const avatarRouter = router({
})
)
.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' })
}
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
}
return getImageUploadUrl(
ctx.user.id,
input.fileName,
input.contentType,
generateAvatarKey
)
}),
/**
@ -54,38 +72,15 @@ export const avatarRouter = router({
.mutation(async ({ ctx, input }) => {
const userId = ctx.user.id
// Use the provider that was used for upload
const provider = createStorageProvider(input.providerType)
const exists = await provider.objectExists(input.key)
if (!exists) {
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 },
await confirmImageUpload(ctx.prisma, avatarConfig, userId, input.key, input.providerType, {
userId: ctx.user.id,
ip: ctx.ip,
userAgent: ctx.userAgent,
})
if (currentUser?.profileImageKey) {
try {
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({
// Return the updated user fields to match original API contract
const user = await ctx.prisma.user.findUnique({
where: { id: userId },
data: {
profileImageKey: input.key,
profileImageProvider: input.providerType,
},
select: {
id: 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
}),
@ -117,71 +95,17 @@ export const avatarRouter = router({
* Get the current user's avatar URL
*/
getUrl: protectedProcedure.query(async ({ ctx }) => {
const userId = 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
return getImageUrl(ctx.prisma, avatarConfig, ctx.user.id)
}),
/**
* Delete the current user's avatar
*/
delete: protectedProcedure.mutation(async ({ ctx }) => {
const userId = ctx.user.id
const user = await ctx.prisma.user.findUnique({
where: { id: userId },
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: {
return deleteImage(ctx.prisma, avatarConfig, ctx.user.id, {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'User',
entityId: userId,
detailsJson: { field: 'profileImageKey' },
ipAddress: ctx.ip,
ip: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
})

View File

@ -1,6 +1,10 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
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({
/**
@ -88,7 +92,7 @@ export const evaluationRouter = router({
.input(
z.object({
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(),
binaryDecision: z.boolean().optional().nullable(),
feedbackText: z.string().optional().nullable(),
@ -133,7 +137,7 @@ export const evaluationRouter = router({
.input(
z.object({
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),
binaryDecision: z.boolean(),
feedbackText: z.string().min(10),
@ -196,25 +200,25 @@ export const evaluationRouter = router({
})
}
// Submit
const updated = await ctx.prisma.evaluation.update({
// Submit evaluation and mark assignment as completed atomically
const [updated] = await ctx.prisma.$transaction([
ctx.prisma.evaluation.update({
where: { id },
data: {
...data,
status: 'SUBMITTED',
submittedAt: now,
},
})
// Mark assignment as completed
await ctx.prisma.assignment.update({
}),
ctx.prisma.assignment.update({
where: { id: evaluation.assignmentId },
data: { isCompleted: true },
})
}),
])
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'EVALUATION_SUBMITTED',
entityType: 'Evaluation',
@ -227,7 +231,6 @@ export const evaluationRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return updated
@ -325,4 +328,297 @@ export const evaluationRouter = router({
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 { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const exportRouter = router({
/**
@ -69,15 +70,14 @@ export const exportRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'Evaluation',
detailsJson: { roundId: input.roundId, count: data.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
@ -154,15 +154,14 @@ export const exportRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'ProjectScores',
detailsJson: { roundId: input.roundId, count: data.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
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
*/

View File

@ -1,7 +1,8 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
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({
/**
@ -19,10 +20,14 @@ export const fileRouter = router({
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
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({
where: { bucket: input.bucket, objectKey: input.objectKey },
select: { projectId: true },
select: {
projectId: true,
roundId: true,
round: { select: { programId: true, sortOrder: true } },
},
})
if (!file) {
@ -32,39 +37,69 @@ export const fileRouter = router({
})
}
// Check if user is assigned as jury or mentor for this project
const [juryAssignment, mentorAssignment] = await Promise.all([
// Check if user is assigned as jury, mentor, or team member for this project
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: file.projectId },
select: { id: true },
select: { id: true, roundId: true },
}),
ctx.prisma.mentorAssignment.findFirst({
where: { mentorId: ctx.user.id, projectId: file.projectId },
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({
code: 'FORBIDDEN',
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
// Log file access
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FILE_DOWNLOADED',
entityType: 'ProjectFile',
detailsJson: { bucket: input.bucket, objectKey: input.objectKey },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
}).catch(() => {})
})
return { url }
}),
@ -83,6 +118,16 @@ export const fileRouter = router({
})
)
.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 objectKey = generateObjectKey(input.projectId, input.fileName)
@ -102,8 +147,8 @@ export const fileRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPLOAD_FILE',
entityType: 'ProjectFile',
@ -115,7 +160,6 @@ export const fileRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
@ -147,12 +191,18 @@ export const fileRouter = router({
where: { id: input.id },
})
// Note: Actual MinIO deletion could be done here or via background job
// For now, we just delete the database record
// Delete actual storage object (best-effort, don't fail the operation)
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
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE_FILE',
entityType: 'ProjectFile',
@ -164,7 +214,6 @@ export const fileRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return file
@ -175,12 +224,15 @@ export const fileRouter = router({
* Checks that the user is authorized to view the project's files
*/
listByProject: protectedProcedure
.input(z.object({ projectId: z.string() }))
.input(z.object({
projectId: z.string(),
roundId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
const isAdmin = ['SUPER_ADMIN', 'PROGRAM_ADMIN'].includes(ctx.user.role)
if (!isAdmin) {
const [juryAssignment, mentorAssignment] = await Promise.all([
const [juryAssignment, mentorAssignment, teamMembership] = await Promise.all([
ctx.prisma.assignment.findFirst({
where: { userId: ctx.user.id, projectId: input.projectId },
select: { id: true },
@ -189,9 +241,19 @@ export const fileRouter = router({
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) {
if (!juryAssignment && !mentorAssignment && !teamMembership) {
throw new TRPCError({
code: 'FORBIDDEN',
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({
where: { projectId: input.projectId },
where,
include: {
round: { select: { id: true, name: true, sortOrder: true } },
},
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,9 +499,12 @@ export const filteringRouter = router({
// Execute rules
const results = await executeFilteringRules(rules, projects)
// Upsert results
// Upsert results in batches to avoid long-held locks
const BATCH_SIZE = 25
for (let i = 0; i < results.length; i += BATCH_SIZE) {
const batch = results.slice(i, i + BATCH_SIZE)
await ctx.prisma.$transaction(
results.map((r) =>
batch.map((r) =>
ctx.prisma.filteringResult.upsert({
where: {
roundId_projectId: {
@ -529,6 +532,7 @@ export const filteringRouter = router({
})
)
)
}
await logAudit({
userId: ctx.user.id,

View File

@ -1,5 +1,6 @@
import { z } from 'zod'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const gracePeriodRouter = router({
/**
@ -24,8 +25,8 @@ export const gracePeriodRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'GRANT_GRACE_PERIOD',
entityType: 'GracePeriod',
@ -38,7 +39,6 @@ export const gracePeriodRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return gracePeriod
@ -119,8 +119,8 @@ export const gracePeriodRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_GRACE_PERIOD',
entityType: 'GracePeriod',
@ -128,7 +128,6 @@ export const gracePeriodRouter = router({
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return gracePeriod
@ -145,8 +144,8 @@ export const gracePeriodRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REVOKE_GRACE_PERIOD',
entityType: 'GracePeriod',
@ -157,7 +156,6 @@ export const gracePeriodRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return gracePeriod
@ -188,8 +186,8 @@ export const gracePeriodRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_GRANT_GRACE_PERIOD',
entityType: 'GracePeriod',
@ -200,7 +198,6 @@ export const gracePeriodRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { created: created.count }

View File

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

View File

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

View File

@ -1,14 +1,44 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
import {
getStorageProviderWithType,
createStorageProvider,
generateLogoKey,
getContentType,
isValidImageType,
type StorageProviderType,
} from '@/lib/storage'
getImageUploadUrl,
confirmImageUpload,
getImageUrl,
deleteImage,
type ImageUploadConfig,
} from '../utils/image-upload'
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({
/**
@ -23,11 +53,6 @@ export const logoRouter = router({
})
)
.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
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
@ -38,17 +63,12 @@ export const logoRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
const key = generateLogoKey(input.projectId, 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
}
return getImageUploadUrl(
input.projectId,
input.fileName,
input.contentType,
generateLogoKey
)
}),
/**
@ -63,38 +83,22 @@ export const logoRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Use the provider that was used for upload
const provider = createStorageProvider(input.providerType)
const exists = await provider.objectExists(input.key)
if (!exists) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Upload not found. Please try uploading again.' })
await confirmImageUpload(
ctx.prisma,
logoConfig,
input.projectId,
input.key,
input.providerType,
{
userId: ctx.user.id,
ip: ctx.ip,
userAgent: ctx.userAgent,
}
// Delete old logo if exists (from its original provider)
const currentProject = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
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
const project = await ctx.prisma.project.update({
// Return the updated project fields to match original API contract
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
data: {
logoKey: input.key,
logoProvider: input.providerType,
},
select: {
id: 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
}),
@ -128,21 +115,7 @@ export const logoRouter = router({
getUrl: adminProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUnique({
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
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
}),
/**
@ -151,46 +124,10 @@ export const logoRouter = router({
delete: adminProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { logoKey: true, logoProvider: true },
})
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: {
return deleteImage(ctx.prisma, logoConfig, input.projectId, {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Project',
entityId: input.projectId,
detailsJson: { field: 'logoKey' },
ipAddress: ctx.ip,
ip: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
}),
})

View File

@ -11,6 +11,7 @@ import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
export const mentorRouter = router({
/**
@ -46,11 +47,10 @@ export const mentorRouter = router({
input.limit
)
// Enrich with mentor details
const enrichedSuggestions = await Promise.all(
suggestions.map(async (suggestion) => {
const mentor = await ctx.prisma.user.findUnique({
where: { id: suggestion.mentorId },
// Enrich with mentor details (batch query to avoid N+1)
const mentorIds = suggestions.map((s) => s.mentorId)
const mentors = await ctx.prisma.user.findMany({
where: { id: { in: mentorIds } },
select: {
id: true,
name: true,
@ -61,7 +61,10 @@ export const mentorRouter = router({
},
},
})
const mentorMap = new Map(mentors.map((m) => [m.id, m]))
const enrichedSuggestions = suggestions.map((suggestion) => {
const mentor = mentorMap.get(suggestion.mentorId)
return {
...suggestion,
mentor: mentor
@ -75,7 +78,6 @@ export const mentorRouter = router({
: null,
}
})
)
return {
currentMentor: null,
@ -117,8 +119,9 @@ export const mentorRouter = router({
where: { id: input.mentorId },
})
// Create assignment
const assignment = await ctx.prisma.mentorAssignment.create({
// Create assignment + audit log in transaction
const assignment = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId: input.mentorId,
@ -146,23 +149,24 @@ export const mentorRouter = router({
},
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'MENTOR_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
entityId: created.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
projectTitle: created.project.title,
mentorId: input.mentorId,
mentorName: assignment.mentor.name,
mentorName: created.mentor.name,
method: input.method,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return created
})
// Get team lead info for mentor notification
@ -291,8 +295,8 @@ export const mentorRouter = router({
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_AUTO_ASSIGN',
entityType: 'MentorAssignment',
@ -307,7 +311,6 @@ export const mentorRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Get team lead info for mentor notification
@ -370,13 +373,10 @@ export const mentorRouter = router({
})
}
await ctx.prisma.mentorAssignment.delete({
where: { projectId: input.projectId },
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
// Delete assignment + audit log in transaction
await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'MENTOR_UNASSIGN',
entityType: 'MentorAssignment',
@ -389,7 +389,11 @@ export const mentorRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
await tx.mentorAssignment.delete({
where: { projectId: input.projectId },
})
})
return { success: true }
@ -517,8 +521,8 @@ export const mentorRouter = router({
}
// Create audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'Round',
@ -530,7 +534,6 @@ export const mentorRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
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)
*/

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import {
NotificationTypes,
} from '../services/in-app-notification'
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
export const projectRouter = router({
/**
@ -55,7 +56,7 @@ export const projectRouter = router({
hasFiles: z.boolean().optional(),
hasAssignments: z.boolean().optional(),
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 }) => {
@ -131,7 +132,6 @@ export const projectRouter = router({
take: perPage,
orderBy: { createdAt: 'desc' },
include: {
files: true,
round: {
select: {
id: true,
@ -139,7 +139,7 @@ export const projectRouter = router({
program: { select: { id: true, name: true, year: true } },
},
},
_count: { select: { assignments: true } },
_count: { select: { assignments: true, files: true } },
},
}),
ctx.prisma.project.count({ where }),
@ -298,7 +298,8 @@ export const projectRouter = router({
)
.mutation(async ({ ctx, input }) => {
const { metadataJson, ...rest } = input
const project = await ctx.prisma.project.create({
const project = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.project.create({
data: {
...rest,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
@ -306,17 +307,18 @@ export const projectRouter = router({
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Project',
entityId: project.id,
entityId: created.id,
detailsJson: { title: input.title, roundId: input.roundId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return created
})
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
if (status) {
// Get round details for notification
@ -458,16 +471,15 @@ export const projectRouter = router({
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Project',
entityId: id,
detailsJson: { ...data, status, metadataJson } as Prisma.InputJsonValue,
detailsJson: { ...data, status, metadataJson } as Record<string, unknown>,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project
@ -479,21 +491,26 @@ export const projectRouter = router({
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.delete({
const project = await ctx.prisma.$transaction(async (tx) => {
const target = await tx.project.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, title: true },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Project',
entityId: input.id,
detailsJson: { title: project.title },
detailsJson: { title: target.title },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return tx.project.delete({
where: { id: input.id },
})
})
return project
@ -560,15 +577,14 @@ export const projectRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'IMPORT',
entityType: 'Project',
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return result
@ -618,40 +634,53 @@ export const projectRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const updated = await ctx.prisma.project.updateMany({
// Fetch matching projects BEFORE update so notifications match actually-updated records
const [projects, round] = await Promise.all([
ctx.prisma.project.findMany({
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([
input.ids.length > 0
? ctx.prisma.project.findMany({
where: { id: { in: input.ids } },
select: { id: true, title: true },
})
: Promise.resolve([]),
}),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
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
const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = {

View File

@ -6,6 +6,7 @@ import {
notifyRoundJury,
NotificationTypes,
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
export const roundRouter = router({
/**
@ -114,7 +115,8 @@ export const roundRouter = router({
const now = new Date()
const shouldAutoActivate = input.votingStartAt && input.votingStartAt <= now
const round = await ctx.prisma.round.create({
const round = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.round.create({
data: {
...rest,
sortOrder,
@ -125,29 +127,31 @@ export const roundRouter = router({
// For FILTERING rounds, automatically move all projects from the program to this round
if (input.roundType === 'FILTERING') {
await ctx.prisma.project.updateMany({
await tx.project.updateMany({
where: {
round: { programId: input.programId },
roundId: { not: round.id },
roundId: { not: created.id },
},
data: {
roundId: round.id,
roundId: created.id,
status: 'SUBMITTED',
},
})
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Round',
entityId: round.id,
detailsJson: { ...rest, settingsJson } as Prisma.InputJsonValue,
entityId: created.id,
detailsJson: { ...rest, settingsJson } as Record<string, unknown>,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return created
})
return round
@ -215,7 +219,8 @@ export const roundRouter = router({
}
}
const round = await ctx.prisma.round.update({
const round = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.round.update({
where: { id },
data: {
...data,
@ -224,17 +229,18 @@ export const roundRouter = router({
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Round',
entityId: id,
detailsJson: { ...data, settingsJson } as Prisma.InputJsonValue,
detailsJson: { ...data, settingsJson } as Record<string, unknown>,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return updated
})
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
const statusActionMap: Record<string, string> = {
ACTIVE: 'ROUND_ACTIVATED',
@ -288,9 +289,14 @@ export const roundRouter = router({
}
const action = statusActionMap[input.status] || 'UPDATE_STATUS'
// Audit log
await ctx.prisma.auditLog.create({
data: {
const round = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.round.update({
where: { id: input.id },
data: updateData,
})
await logAudit({
prisma: tx,
userId: ctx.user.id,
action,
entityType: 'Round',
@ -306,7 +312,9 @@ export const roundRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return updated
})
// Notify jury members when round is activated
@ -435,9 +443,25 @@ export const roundRouter = router({
id: z.string(),
label: z.string().min(1),
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(),
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,8 +509,8 @@ export const roundRouter = router({
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_EVALUATION_FORM',
entityType: 'EvaluationForm',
@ -494,7 +518,6 @@ export const roundRouter = router({
detailsJson: { roundId, criteriaCount: criteria.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return form
@ -525,13 +548,9 @@ export const roundRouter = router({
},
})
await ctx.prisma.round.delete({
where: { id: input.id },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Round',
@ -544,7 +563,11 @@ export const roundRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
await tx.round.delete({
where: { id: input.id },
})
})
return round
@ -601,8 +624,8 @@ export const roundRouter = router({
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ASSIGN_PROJECTS_TO_ROUND',
entityType: 'Round',
@ -610,7 +633,6 @@ export const roundRouter = router({
detailsJson: { projectCount: updated.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { assigned: updated.count }
@ -640,8 +662,8 @@ export const roundRouter = router({
const deleted = { count: updated.count }
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REMOVE_PROJECTS_FROM_ROUND',
entityType: 'Round',
@ -649,7 +671,6 @@ export const roundRouter = router({
detailsJson: { projectCount: deleted.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { removed: deleted.count }
@ -711,8 +732,8 @@ export const roundRouter = router({
const created = { count: updated.count }
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ADVANCE_PROJECTS',
entityType: 'Round',
@ -724,7 +745,6 @@ export const roundRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { advanced: created.count }
@ -752,8 +772,8 @@ export const roundRouter = router({
)
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REORDER_ROUNDS',
entityType: 'Program',
@ -761,7 +781,6 @@ export const roundRouter = router({
detailsJson: { roundIds: input.roundIds },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }

View File

@ -3,6 +3,8 @@ import { router, adminProcedure, superAdminProcedure, protectedProcedure } from
import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
import { listAvailableModels, testOpenAIConnection, isReasoningModel } from '@/lib/openai'
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
@ -117,9 +119,14 @@ 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)
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_SETTING',
entityType: 'SystemSettings',
@ -130,7 +137,6 @@ export const settingsRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
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
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_SETTINGS_BATCH',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return results
@ -346,8 +356,8 @@ export const settingsRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_NOTIFICATION_PREFERENCES',
entityType: 'User',
@ -358,7 +368,6 @@ export const settingsRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return user

View File

@ -97,7 +97,8 @@ export const specialAwardRouter = router({
_max: { sortOrder: true },
})
const award = await ctx.prisma.specialAward.create({
const award = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.specialAward.create({
data: {
programId: input.programId,
name: input.name,
@ -111,12 +112,17 @@ export const specialAwardRouter = router({
},
})
await logAudit({
await tx.auditLog.create({
data: {
userId: ctx.user.id,
action: 'CREATE',
entityType: 'SpecialAward',
entityId: award.id,
detailsJson: { name: input.name, scoringMode: input.scoringMode },
entityId: created.id,
detailsJson: { name: input.name, scoringMode: input.scoringMode } as Prisma.InputJsonValue,
},
})
return created
})
return award
@ -166,13 +172,17 @@ export const specialAwardRouter = router({
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.specialAward.delete({ where: { id: input.id } })
await logAudit({
await ctx.prisma.$transaction(async (tx) => {
await tx.auditLog.create({
data: {
userId: ctx.user.id,
action: 'DELETE',
entityType: 'SpecialAward',
entityId: input.id,
},
})
await tx.specialAward.delete({ where: { id: input.id } })
})
}),
@ -216,12 +226,14 @@ export const specialAwardRouter = router({
}
}
const award = await ctx.prisma.specialAward.update({
const award = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.specialAward.update({
where: { id: input.id },
data: updateData,
})
await logAudit({
await tx.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE_STATUS',
entityType: 'SpecialAward',
@ -234,9 +246,13 @@ export const specialAwardRouter = router({
previousVotingStartAt: current.votingStartAt,
newVotingStartAt: now,
}),
} as Prisma.InputJsonValue,
},
})
return updated
})
return award
}),
@ -780,7 +796,8 @@ export const specialAwardRouter = router({
select: { winnerProjectId: true },
})
const award = await ctx.prisma.specialAward.update({
const award = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.specialAward.update({
where: { id: input.awardId },
data: {
winnerProjectId: input.projectId,
@ -789,7 +806,8 @@ export const specialAwardRouter = router({
},
})
await logAudit({
await tx.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'SpecialAward',
@ -799,9 +817,13 @@ export const specialAwardRouter = router({
previousWinner: previous.winnerProjectId,
newWinner: input.projectId,
overridden: input.overridden,
} as Prisma.InputJsonValue,
},
})
return updated
})
return award
}),
})

View File

@ -2,6 +2,7 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { prisma } from '@/lib/prisma'
import { logAudit } from '../utils/audit'
import {
tagProject,
getTagSuggestions,
@ -299,8 +300,8 @@ export const tagRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'ExpertiseTag',
@ -308,7 +309,6 @@ export const tagRouter = router({
detailsJson: { name: input.name, category: input.category },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return tag
@ -399,8 +399,8 @@ export const tagRouter = router({
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'ExpertiseTag',
@ -408,7 +408,6 @@ export const tagRouter = router({
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return tag
@ -460,8 +459,8 @@ export const tagRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'ExpertiseTag',
@ -469,7 +468,6 @@ export const tagRouter = router({
detailsJson: { name: tag.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return tag
@ -520,15 +518,14 @@ export const tagRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'ExpertiseTag',
detailsJson: { count: created.count, skipped: existingNames.size },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { created: created.count, skipped: existingNames.size }
@ -608,8 +605,8 @@ export const tagRouter = router({
const result = await tagProject(input.projectId, ctx.user.id)
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'AI_TAG',
entityType: 'Project',
@ -620,7 +617,6 @@ export const tagRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return result
@ -669,8 +665,8 @@ export const tagRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'START_AI_TAG_JOB',
entityType: input.programId ? 'Program' : 'Round',
@ -678,7 +674,6 @@ export const tagRouter = router({
detailsJson: { jobId: job.id },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// 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
*/
@ -827,8 +769,8 @@ export const tagRouter = router({
await addProjectTag(input.projectId, input.tagId)
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'ADD_TAG',
entityType: 'Project',
@ -836,7 +778,6 @@ export const tagRouter = router({
detailsJson: { tagId: input.tagId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }
@ -856,8 +797,8 @@ export const tagRouter = router({
await removeProjectTag(input.projectId, input.tagId)
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'REMOVE_TAG',
entityType: 'Project',
@ -865,7 +806,6 @@ export const tagRouter = router({
detailsJson: { tagId: input.tagId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true }

View File

@ -6,6 +6,7 @@ import { router, protectedProcedure, adminProcedure, superAdminProcedure, public
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
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
@ -146,9 +147,10 @@ export const userRouter = router({
})
}
// Audit log before deletion
await ctx.prisma.auditLog.create({
data: {
// Wrap audit + deletion in a transaction
await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE_OWN_ACCOUNT',
entityType: 'User',
@ -156,13 +158,12 @@ export const userRouter = router({
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Delete the user
await ctx.prisma.user.delete({
await tx.user.delete({
where: { id: ctx.user.id },
})
})
return { success: true }
}),
@ -243,8 +244,6 @@ export const userRouter = router({
get: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
console.log('[user.get] Fetching user:', input.id)
try {
const user = await ctx.prisma.user.findUniqueOrThrow({
where: { id: input.id },
include: {
@ -253,12 +252,7 @@ export const userRouter = router({
},
},
})
console.log('[user.get] Found user:', user.email)
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) => {
const created = await tx.user.create({
data: {
...input,
status: 'INVITED',
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'User',
entityId: user.id,
entityId: created.id,
detailsJson: { email: input.email, role: input.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return created
})
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) => {
const updated = await tx.user.update({
where: { id },
data,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'User',
@ -370,13 +366,12 @@ export const userRouter = router({
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Track role change specifically
if (data.role && data.role !== targetUser.role) {
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'ROLE_CHANGED',
entityType: 'User',
@ -384,10 +379,12 @@ export const userRouter = router({
detailsJson: { previousRole: targetUser.role, newRole: data.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
}).catch(() => {})
})
}
return updated
})
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) => {
// 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 ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'User',
entityId: input.id,
detailsJson: { email: user.email },
detailsJson: { email: target.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return tx.user.delete({
where: { id: input.id },
})
})
return user
@ -497,15 +500,14 @@ export const userRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'User',
detailsJson: { count: created.count, skipped, duplicatesInInput },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Auto-send invitation emails to newly created users
@ -541,15 +543,14 @@ export const userRouter = router({
// Audit log for assignments if any were created
if (assignmentsCreated > 0) {
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_ASSIGN',
entityType: 'Assignment',
detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
}
@ -699,8 +700,8 @@ export const userRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_INVITATION',
entityType: 'User',
@ -708,7 +709,6 @@ export const userRouter = router({
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true, email: user.email }
@ -777,15 +777,14 @@ export const userRouter = router({
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_SEND_INVITATIONS',
entityType: 'User',
detailsJson: { sent, errors },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { sent, skipped: input.userIds.length - users.length, errors }
@ -817,7 +816,8 @@ export const userRouter = router({
const userTags = input.expertiseTags || []
const mergedTags = [...new Set([...adminTags, ...userTags])]
const user = await ctx.prisma.user.update({
const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id: ctx.user.id },
data: {
name: input.name,
@ -831,9 +831,8 @@ export const userRouter = router({
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'COMPLETE_ONBOARDING',
entityType: 'User',
@ -841,7 +840,9 @@ export const userRouter = router({
detailsJson: { name: input.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return updated
})
return user
@ -908,8 +909,9 @@ export const userRouter = router({
// Hash the password
const passwordHash = await hashPassword(input.password)
// Update user with new password
const user = await ctx.prisma.user.update({
// Update user with new password + audit in transaction
const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
@ -918,9 +920,8 @@ export const userRouter = router({
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'PASSWORD_SET',
entityType: 'User',
@ -928,7 +929,9 @@ export const userRouter = router({
detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return updated
})
return { success: true, email: user.email }
@ -989,8 +992,9 @@ export const userRouter = router({
// Hash the new password
const passwordHash = await hashPassword(input.newPassword)
// Update user with new password
await ctx.prisma.user.update({
// Update user with new password + audit in transaction
await ctx.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
@ -998,9 +1002,8 @@ export const userRouter = router({
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'PASSWORD_CHANGED',
entityType: 'User',
@ -1008,7 +1011,7 @@ export const userRouter = router({
detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
})
return { success: true }
@ -1047,8 +1050,8 @@ export const userRouter = router({
// The actual email is sent through NextAuth's email provider
// Audit log (without user ID since this is public)
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: null, // No authenticated user
action: 'REQUEST_PASSWORD_RESET',
entityType: 'User',
@ -1056,7 +1059,6 @@ export const userRouter = router({
detailsJson: { email: input.email, timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
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 {
anonymizeProjectsForAI,
validateAnonymizedProjects,
type ProjectWithRelations,
toProjectWithRelations,
type AnonymizedProjectForAI,
type ProjectAIMapping,
} from './anonymization'
@ -131,32 +131,6 @@ function getFieldValue(
// ─── 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
*/

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 {
anonymizeProjectsForAI,
validateAnonymizedProjects,
type ProjectWithRelations,
toProjectWithRelations,
type AnonymizedProjectForAI,
type ProjectAIMapping,
} from './anonymization'
@ -275,32 +275,6 @@ interface AIScreeningResult {
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
*/

View File

@ -22,9 +22,8 @@ import { classifyAIError, createParseError, logAIError } from './ai-errors'
import {
anonymizeProjectsForAI,
validateAnonymizedProjects,
type ProjectWithRelations,
toProjectWithRelations,
type AnonymizedProjectForAI,
type ProjectAIMapping,
} from './anonymization'
// ─── Types ──────────────────────────────────────────────────────────────────
@ -43,14 +42,6 @@ export interface TaggingResult {
tokensUsed: number
}
export interface BatchTaggingResult {
processed: number
failed: number
skipped: number
errors: string[]
results: TaggingResult[]
}
interface AvailableTag {
id: string
name: string
@ -60,8 +51,6 @@ interface AvailableTag {
// ─── Constants ───────────────────────────────────────────────────────────────
const DEFAULT_BATCH_SIZE = 10
const MAX_BATCH_SIZE = 25
const CONFIDENCE_THRESHOLD = 0.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 ─────────────────────────────────────────────────────────
/**
@ -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
* Useful for preview/review before applying

View File

@ -132,6 +132,51 @@ export interface ProjectAIMapping {
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) ────────────────────────────────
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)
* - Workload balance
* - 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)
* - Bio match: 0-15 points (if bio exists)
* - Workload balance: 0-25 points
* - 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'
@ -24,7 +29,9 @@ export interface ScoreBreakdown {
bioMatch: number
workloadBalance: number
countryMatch: number
aiBoost: number
geoDiversityPenalty: number
previousRoundFamiliarity: number
coiPenalty: number
}
export interface AssignmentScore {
@ -53,6 +60,12 @@ const MAX_WORKLOAD_SCORE = 25
const MAX_COUNTRY_SCORE = 15
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
const STOP_WORDS = new Set([
'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}`)
)
// 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)
// Calculate scores for all user-project pairs
// ── Calculate scores for all user-project pairs ───────────────────────────
const suggestions: AssignmentScore[] = []
for (const user of users) {
@ -305,6 +376,11 @@ export async function getSmartSuggestions(options: {
continue
}
// COI check - skip juror entirely for this project if COI declared
if (coiPairs.has(pairKey)) {
continue
}
// Get project tags data
const projectTags: ProjectTagData[] = project.projectTags.map((pt) => ({
tagId: pt.tagId,
@ -312,13 +388,12 @@ export async function getSmartSuggestions(options: {
confidence: pt.confidence,
}))
// Calculate scores
// Calculate existing scores
const { score: tagScore, matchingTags } = calculateTagOverlapScore(
user.expertiseTags,
projectTags
)
// Bio match (only if user has a bio)
const { score: bioScore, matchingKeywords } = calculateBioMatchScore(
user.bio,
project.description
@ -330,13 +405,39 @@ export async function getSmartSuggestions(options: {
user.maxAssignments
)
// Country match only for mentors
const countryScore =
type === 'mentor'
? calculateCountryMatchScore(user.country, project.country)
: 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
const reasoning: string[] = []
@ -354,6 +455,12 @@ export async function getSmartSuggestions(options: {
if (countryScore > 0) {
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({
userId: user.id,
@ -367,7 +474,9 @@ export async function getSmartSuggestions(options: {
bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
aiBoost: 0,
geoDiversityPenalty,
previousRoundFamiliarity,
coiPenalty: 0, // COI jurors are skipped entirely
},
reasoning,
matchingTags,
@ -490,7 +599,9 @@ export async function getMentorSuggestionsForProject(
bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
aiBoost: 0,
geoDiversityPenalty: 0,
previousRoundFamiliarity: 0,
coiPenalty: 0,
},
reasoning,
matchingTags,

View File

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

View File

@ -1,11 +1,19 @@
import { prisma } from '@/lib/prisma'
import type { Prisma } from '@prisma/client'
import { prisma as globalPrisma } from '@/lib/prisma'
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.
* 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: {
prisma?: AuditPrismaClient
userId?: string | null
action: string
entityType: string
@ -15,7 +23,8 @@ export async function logAudit(input: {
userAgent?: string
}): Promise<void> {
try {
await prisma.auditLog.create({
const db = input.prisma ?? globalPrisma
await db.auditLog.create({
data: {
userId: input.userId ?? null,
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 }
}