MOPC-App/docs/platform-review.md

1837 lines
91 KiB
Markdown

# MOPC Platform Review
> **Status Legend**: Each finding is marked with its implementation status:
> - **DONE** - Implemented and verified
> - **DEFERRED** - Cannot be done now (requires infrastructure, design decisions, or is too risky)
> - **N/A** - Not actionable (positive observation or informational)
> - **SKIPPED** - Intentionally not addressed (low impact or not worth the risk)
## 1. UI/UX Design Review
**Reviewer**: UI/UX Design Reviewer
**Date**: 2026-02-05
**Scope**: All pages, components, design patterns, mobile responsiveness, UX states, information architecture, interaction patterns, accessibility, visual polish
---
### Overall Assessment
The MOPC platform demonstrates strong foundational UI/UX work. shadcn/ui provides a consistent base, skeleton loading states are implemented throughout, and the evaluation form offers excellent UX with autosave and slider+button scoring. However, there are meaningful gaps in mobile responsiveness, navigation consistency, typography adherence, and error states that should be addressed before a public-facing launch.
---
### 1.1 Design System & Theming
#### DS-1: Public layout uses Inter font instead of Montserrat (Medium) — **DONE**
**File**: `src/app/(public)/layout.tsx:4-5`
The public layout imports Google's Inter font and applies it to the entire public section. The brand identity specifies Montserrat as the platform typeface (defined in `globals.css` via `@font-face`). Every other layout uses the root font (Montserrat via `font-sans`), but public pages render in Inter.
```tsx
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
```
**Impact**: Brand inconsistency on the most externally-visible pages.
**Recommendation**: Remove the Inter import and use the existing Montserrat font from the root layout. Apply `className="font-sans"` if needed.
---
#### DS-2: Public layout uses placeholder logo instead of MOPC logo component (Low) — **DONE**
**File**: `src/app/(public)/layout.tsx:20-22`
The public layout renders a hardcoded `<div>` with "M" text instead of using the existing `Logo` component at `src/components/shared/logo.tsx`.
```tsx
<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>
```
**Recommendation**: Import and use the `Logo` component with `variant="small"`.
---
#### DS-3: Dark mode CSS defined but no toggle exposed (Low) — **DONE**
**File**: `src/app/globals.css` (dark mode variables block)
Full dark mode color tokens are defined in globals.css under `@media (prefers-color-scheme: dark)`, but there is no theme toggle anywhere in the UI. Users with dark system preferences will see the dark theme, but cannot switch. This creates an untested UX path.
**Recommendation**: Either add a theme toggle in settings/nav (using `next-themes`), or remove the dark mode definitions to ensure consistent appearance. If dark mode is deferred, add `class="light"` to the `<html>` tag to force light mode.
---
#### DS-4: Card `CardTitle` default size is too large and always overridden (Low) — **DONE**
**File**: `src/components/ui/card.tsx`
`CardTitle` defaults to `text-2xl tracking-tight`, but nearly every usage in the codebase adds custom sizing classes. This suggests the default is not useful.
**Recommendation**: Change the default to `text-lg font-semibold` which matches the most common override pattern.
---
### 1.2 Navigation & Information Architecture
#### NAV-1: Inconsistent active state styling across role navigation components (Medium) — **DONE**
**Files**:
- `src/components/layouts/jury-nav.tsx` - Uses `bg-primary/10 text-primary`
- `src/components/layouts/mentor-nav.tsx` - Uses `bg-primary/10 text-primary`
- `src/components/layouts/observer-nav.tsx` - Uses `bg-primary text-primary-foreground` (solid background)
The observer nav uses a completely different active state style (solid filled background) compared to jury and mentor navs (subtle tinted background). This inconsistency is jarring for users who switch between roles.
**Recommendation**: Unify active state styling. Better yet, extract all three into a shared `RoleNav` component as noted in the code quality review (4.1.4).
---
#### NAV-2: Notification bell links are hardcoded to admin paths (Medium) — **DONE**
**File**: `src/components/shared/notification-bell.tsx`
The notification bell component links to `/admin/settings` and `/admin/notifications` regardless of the user's role. Jury members, mentors, and observers will be redirected or see 403 errors when clicking these links.
**Recommendation**: Use role-aware paths (e.g., `/${role}/notifications`) or a role-neutral `/notifications` route.
---
#### NAV-3: Jury nav is missing Awards link (Low) — **DONE**
**File**: `src/components/layouts/jury-nav.tsx`
The jury navigation includes Dashboard, My Assignments, and Learning Hub. There is no link to special awards even though jury members may need to view award-eligible projects. The admin sidebar includes awards management, but there is no jury-facing awards view link.
**Recommendation**: Add an Awards link to the jury navigation if jury members should be able to view special award results.
---
#### NAV-4: Admin sidebar user section uses hardcoded colors (Low) — **DONE**
**File**: `src/components/layouts/admin-sidebar.tsx:247-267`
The user info section at the bottom of the admin sidebar uses hardcoded `slate-200`, `slate-300`, `slate-700`, `slate-400` colors instead of semantic tokens like `muted`, `muted-foreground`, `border`.
**Recommendation**: Replace with semantic color tokens for consistency with the rest of the design system and proper dark mode support.
---
### 1.3 Mobile Responsiveness
#### MOB-1: Admin rounds table is not responsive (High) — **DONE**
**File**: `src/app/(admin)/admin/rounds/page.tsx`
The rounds management page uses a fixed 7-column grid (`grid-cols-7`) that does not adapt to mobile or tablet screens. On narrow viewports, content will overflow or become illegible. Unlike the projects page (which has separate table/card views), rounds has no mobile layout.
**Recommendation**: Implement a mobile card layout for rounds similar to the project list's mobile view. Show the full table only on `lg:` screens.
---
#### MOB-2: Pagination component is not mobile-optimized (Medium) — **DONE**
**File**: `src/components/shared/pagination.tsx`
The pagination component renders all page numbers without truncation. For datasets with many pages, this creates a long horizontal row that overflows on mobile.
**Recommendation**: Show abbreviated pagination on mobile: first, previous, current, next, last. Use `hidden sm:flex` to show the full pagination only on larger screens.
---
#### MOB-3: Admin dashboard stat cards lack stacking on small screens (Low) — **DONE**
**File**: `src/app/(admin)/admin/page.tsx`
The admin dashboard uses `grid-cols-2 md:grid-cols-4` for the stats grid. On very narrow phones (< 360px), two columns may still be too wide for the stat cards.
**Recommendation**: Consider `grid-cols-1 sm:grid-cols-2 md:grid-cols-4` for the narrowest phones.
---
### 1.4 UX States & Feedback
#### UX-1: No global 404 or error pages (Medium) — **DONE**
**Files**: Only `src/app/(auth)/error/page.tsx` exists (auth-specific errors)
There is no `src/app/not-found.tsx` or `src/app/error.tsx`. Users who navigate to non-existent routes will see Next.js's default 404 page, which has no MOPC branding, navigation, or helpful direction.
**Recommendation**: Create `src/app/not-found.tsx` with MOPC branding, a message, and a link back to the dashboard. Create `src/app/error.tsx` for runtime errors.
---
#### UX-2: Profile settings page has three identical save buttons (Medium) — **DONE**
**File**: `src/app/(settings)/settings/profile/page.tsx:240-252, 286-298, 321-333`
The profile page has three separate "Save" buttons (Save Changes, Save Preferences, Save Expertise) that all call the same `handleSaveProfile` function. This means clicking "Save Expertise" also saves any pending name/bio changes, which may confuse users.
**Recommendation**: Either use a single save button at the bottom of the page for all profile fields, or make each section save independently by splitting the mutation into separate API calls.
---
#### UX-3: Form state initialized via conditional in render body (Low) — **DONE**
**File**: `src/app/(settings)/settings/profile/page.tsx:76-84`
```tsx
if (user && !profileLoaded) {
setName(user.name || '')
// ...
setProfileLoaded(true)
}
```
This pattern of calling `setState` during render is fragile. React may batch these calls unpredictably, and the `profileLoaded` guard can fail during concurrent rendering.
**Recommendation**: Use `useEffect` to populate form state when user data loads, or use React Hook Form with `defaultValues` from the query data.
---
#### UX-4: Mentor dashboard uses client-side loading instead of Suspense (Low) — **DONE**
**File**: `src/app/(mentor)/mentor/page.tsx`
The mentor dashboard conditionally renders a loading spinner based on `isLoading` from tRPC queries, rather than using React Suspense boundaries. Other dashboards (admin) use server components. This creates inconsistency in the loading experience.
**Recommendation**: Convert to a server component with Suspense, or at minimum use skeleton loading states (as the admin dashboard does) instead of a centered spinner.
---
### 1.5 Interaction Patterns
#### INT-1: Evaluation form is excellent (Positive) — **N/A**
**File**: `src/components/forms/evaluation-form.tsx`
The evaluation form demonstrates outstanding UX:
- Autosave with debounce and visual status indicator
- Dual input for scores: slider drag + direct button click
- Score label changes dynamically based on value
- Sticky status bar showing save state
- Confirmation dialog before final submission
- Read-only mode for past evaluations
- Character counter on text fields
This should be the UX benchmark for all forms in the platform.
---
#### INT-2: Button press feedback is well-implemented (Positive) — **N/A**
**File**: `src/components/ui/button.tsx`
All buttons include `active:scale-[0.98]` for tactile press feedback. Combined with proper `disabled` states and loading spinners (`Loader2` with `animate-spin`), the button interactions feel responsive and polished.
---
#### INT-3: File upload component is well-designed (Positive) — **N/A**
**File**: `src/components/shared/file-upload.tsx`
The file upload supports drag-and-drop with visual hover state, progress bar during upload, file type and size validation with clear error messages, and a preview of uploaded files. This is production-ready UX.
---
#### INT-4: Onboarding wizard has strong UX (Positive) — **N/A**
**File**: `src/app/(auth)/onboarding/page.tsx`
The multi-step onboarding uses:
- Brand gradient background for visual distinction
- Step indicator with progress
- Form validation per step
- Expertise tag selection with TagInput component
- Smooth transitions between steps
---
### 1.6 Accessibility
#### A11Y-1: Muted foreground color may have insufficient contrast (Medium) — **DONE**
**File**: `src/app/globals.css`
The `--muted-foreground` color (used for secondary text, placeholders, descriptions) is defined as `hsl(215.4 16.3% 46.9%)` in light mode. This corresponds to approximately `#6b7280` which provides ~4.6:1 contrast against white. While this passes WCAG AA for normal text (4.5:1), it fails for small text and is very close to the threshold.
Many description texts and helper texts throughout the platform use `text-muted-foreground` at small sizes (`text-xs`, `text-sm`).
**Recommendation**: Darken `--muted-foreground` slightly to ensure comfortable reading, especially for `text-xs` usage. Aim for at least 5:1 contrast ratio.
---
#### A11Y-2: Focus styles are properly implemented (Positive) — **N/A**
**File**: `src/app/globals.css`, `src/components/ui/button.tsx`
The design system includes `focus-visible:ring-2 focus-visible:ring-ring` on interactive elements. This provides clear keyboard navigation indicators without affecting mouse users. This is correctly implemented across all shadcn/ui base components.
---
#### A11Y-3: Skeleton loading states provide good screen reader context (Positive) — **N/A**
**Files**: Multiple pages using `<Skeleton>` components
Loading states use semantic skeleton placeholders instead of spinners, which gives screen readers (via `aria-busy` on parent containers) context about what is loading.
---
### 1.7 Visual Polish
#### VP-1: Landing page is minimal and unbranded (Medium) — **DONE**
**File**: `src/app/page.tsx`
The landing/home page is very minimal. For a platform representing the Monaco Ocean Protection Challenge, the entry point should convey the mission and brand more strongly.
**Recommendation**: Enhance the landing page with the MOPC logo, brand colors, a brief mission statement, and a clear CTA to login or learn more. Use the brand gradient seen in the onboarding flow.
---
#### VP-2: Auth error page is generic (Low) — **DONE**
**File**: `src/app/(auth)/error/page.tsx`
The auth error page shows a basic card with the error message. It does not use MOPC branding or provide helpful navigation (e.g., "Return to Login" or "Contact Support").
**Recommendation**: Add MOPC branding, a supportive error message, and navigation options.
---
#### VP-3: Observer dashboard "read-only" notice could be more prominent (Low) — **DONE**
**File**: `src/app/(observer)/observer/page.tsx`
The observer dashboard shows a subtle info card noting read-only access. Given that observers might expect interactivity, this notice could be more visually distinct.
**Recommendation**: Use a persistent banner or badge-style indicator near the page header.
---
### 1.8 Summary Table
| Category | Findings | High | Medium | Low | Positive |
|----------|----------|------|--------|-----|----------|
| Design System & Theming | 4 | 0 | 1 | 3 | 0 |
| Navigation & IA | 4 | 0 | 2 | 2 | 0 |
| Mobile Responsiveness | 3 | 1 | 1 | 1 | 0 |
| UX States & Feedback | 4 | 0 | 2 | 2 | 0 |
| Interaction Patterns | 4 | 0 | 0 | 0 | 4 |
| Accessibility | 3 | 0 | 1 | 0 | 2 |
| Visual Polish | 3 | 0 | 1 | 2 | 0 |
| **Total** | **25** | **1** | **8** | **10** | **6** |
---
### Quick Wins (< 1 hour each)
1. **DS-1**: Replace Inter with Montserrat in public layout
2. **DS-2**: Use Logo component in public layout
3. **NAV-4**: Replace hardcoded slate colors with semantic tokens
4. **UX-3**: Move form state initialization to useEffect
5. **VP-2**: Add branding and navigation to auth error page
6. **DS-4**: Adjust CardTitle default size
7. **NAV-3**: Add Awards link to jury nav (if applicable)
### Longer-Term Improvements
1. **MOB-1**: Build responsive card layout for rounds table (High priority)
2. **UX-1**: Create global 404 and error pages
3. **NAV-1/NAV-2**: Unify navigation components and fix notification paths
4. **A11Y-1**: Audit and improve muted-foreground contrast
5. **VP-1**: Redesign landing page with full MOPC branding
6. **MOB-2**: Implement truncated mobile pagination
7. **UX-2**: Fix profile settings save button behavior
8. **DS-3**: Either implement dark mode toggle or force light mode
9. **UX-4**: Standardize loading patterns across dashboards
---
## 3. Database Schema & Implementation Review
**Reviewer**: Database & Backend Reviewer
**Date**: 2026-02-05
**Scope**: Prisma schema, tRPC routers, services, indexing, query patterns, data integrity
---
### Overall Assessment
The schema is well-structured for its domain with good use of Prisma features. The codebase demonstrates solid TypeScript/Prisma patterns, proper use of enums, composite unique constraints, and consistent audit logging. However, there are several areas where improvements in indexing, query efficiency, transaction usage, and data integrity would enhance performance and reliability, particularly as the platform scales beyond Phase 1.
---
### Schema Design
#### SD-1: Project.roundId is required but treated as optional (High) — **DEFERRED** (requires design decision on pool project architecture)
**Files**: `prisma/schema.prisma:417`, `src/server/routers/round.ts:631-639`
The `Project` model declares `roundId String` as a required (non-nullable) field and has a required relation `round Round @relation(...)`. However, in `round.removeProjects`, the code sets `roundId: null as unknown as string`, which is a type-unsafe workaround to force a null into a non-nullable field. Similarly, `project.listPool` queries for `roundId: null`, which would never match if the schema is enforced.
**Impact**: Data integrity violation; Prisma may not enforce this at the DB level with `@default()` but PostgreSQL will reject nulls on a NOT NULL column.
**Recommendation**: Either make `roundId` optional (`String?`) with an optional relation if projects can be unassigned from rounds, or implement a "pool" round concept. The current workaround is fragile and could fail at the database level.
---
#### SD-2: Evaluation versioning is declared but not implemented (Medium) — **DONE** (documented as deferred in schema with TODO comment)
**Files**: `prisma/schema.prisma:570`, `src/server/routers/evaluation.ts`
The `Evaluation` model has a `version Int @default(1)` field and the CLAUDE.md states "Evaluations are versioned - edits create new versions." However, the evaluation router's `autosave` and `submit` mutations update in-place and never create new versioned rows. The version field is never incremented.
**Impact**: The stated requirement for version history is not fulfilled. If an admin or auditor needs to see how an evaluation changed over time, that data is lost.
**Recommendation**: Either implement proper versioning (create new Evaluation rows with incremented version on re-submission) or remove the `version` field to avoid confusion. If versioning is deferred, document it clearly.
---
#### SD-3: Soft deletes not implemented (Medium) — **DEFERRED** (massive schema change, requires careful planning)
**Files**: All routers using `.delete()`, `prisma/schema.prisma`
The CLAUDE.md architecture decisions (ADR #7) state "Soft deletes: Audit trail, recovery, referential integrity." However, no model has a `deletedAt` field, and all delete operations use hard deletes (e.g., `project.delete`, `user.delete`, `round.delete`). The `User.delete` cascades through assignments, evaluations, and other relations.
**Impact**: Once deleted, data cannot be recovered. For a jury voting platform where audit trails are critical, this is a significant gap. Deleting a user cascades through assignments and evaluations, potentially destroying evaluation data.
**Recommendation**: Add `deletedAt DateTime?` to critical models (`User`, `Project`, `Evaluation`, `Assignment`), implement soft delete middleware via Prisma's `$extends`, and ensure all queries filter out soft-deleted records.
---
#### SD-4: LiveVotingSession.status is a String, not an Enum (Low) — **DONE**
**Files**: `prisma/schema.prisma:918`
The `LiveVotingSession.status` field is typed as `String @default("NOT_STARTED")` with valid values documented in a comment. Other status fields use proper enums.
**Impact**: No database-level validation of valid status values.
**Recommendation**: Create a `LiveVotingSessionStatus` enum with values `NOT_STARTED`, `IN_PROGRESS`, `PAUSED`, `COMPLETED`.
---
#### SD-5: InAppNotification.priority and type are Strings, not Enums (Low) — **DONE**
**Files**: `prisma/schema.prisma:737-738`
Similar to SD-4, `priority` and `type` on `InAppNotification` are strings but have a fixed set of known values. The service defines `NotificationPriority` as a TypeScript type but it's not enforced at the database level.
**Recommendation**: Create enums for `priority` (low/normal/high/urgent). The `type` field has many values that expand over time, so keeping it as a string is acceptable, but `priority` should be an enum.
---
#### SD-6: Redundant date fields on Round (Low) — **DEFERRED** (fields still actively used in application/round routers)
**Files**: `prisma/schema.prisma:348-353`
The `Round` model has both `submissionDeadline` and `submissionEndDate` with a comment "replaces submissionDeadline if set". Additionally, `phase1Deadline` and `phase2Deadline` exist but appear unused in any router. This creates confusion about which fields are authoritative.
**Recommendation**: Remove deprecated fields (`submissionDeadline` if replaced by `submissionEndDate`) and either implement or remove `phase1Deadline`/`phase2Deadline`.
---
### Indexing
#### IX-1: Missing composite index on Evaluation for round-based queries (High) — **DONE**
**Files**: `prisma/schema.prisma:554-584`
Many queries filter evaluations by `assignment.roundId` (e.g., `evaluation.listByRound`, all analytics queries, `export.evaluations`). This requires a join through the `Assignment` table. There is no index on `Evaluation.formId` paired with `status`, and more critically, the join through `assignment.roundId` means these queries perform a nested lookup.
**Impact**: As evaluation counts grow (130+ projects x 3+ reviews = 390+ evaluations per round), these queries will slow down.
**Recommendation**: Consider denormalizing `roundId` onto the `Evaluation` table or adding a database view. At minimum, ensure the existing `@@index([status])` and `@@index([formId])` are sufficient with the join pattern.
---
#### IX-2: AuditLog table will grow unbounded (Medium) — **DEFERRED** (requires DBA decision on partitioning strategy)
**Files**: `prisma/schema.prisma:638-663`
The `AuditLog` table has good indexes (`userId`, `action`, `entityType+entityId`, `timestamp`) but no partitioning or archival strategy. With every CRUD operation, status change, login, and export generating audit entries, this table will grow rapidly.
**Impact**: Query performance degradation over time, especially for `audit.getStats` which does `groupBy` operations across the entire table.
**Recommendation**: Implement time-based partitioning on `timestamp`, or add an archival job that moves old audit logs to a separate table. Consider adding a composite index on `(entityType, entityId, timestamp)` for the `getByEntity` query.
---
#### IX-3: User.email has both @unique and @@index (Low) — **DONE**
**Files**: `prisma/schema.prisma:192, 262`
`email String @unique` already creates a unique index. The additional `@@index([email])` is redundant.
**Impact**: Extra write overhead on every user insert/update for maintaining a duplicate index.
**Recommendation**: Remove `@@index([email])`.
---
#### IX-4: Missing index on GracePeriod for combined lookups (Low) — **DONE**
**Files**: `prisma/schema.prisma:590-612`
The `evaluation.submit` mutation queries grace periods with `roundId + userId + extendedUntil + projectId`. Individual indexes exist but no composite index covers this query pattern.
**Recommendation**: Add `@@index([roundId, userId, extendedUntil])` for the common grace period lookup pattern.
---
### Query Optimization
#### QO-1: Analytics getProjectRankings loads all project data including all assignments and evaluations (High) — **DONE**
**Files**: `src/server/routers/analytics.ts:148-207`
The `getProjectRankings` query does `include: { assignments: { include: { evaluation: ... } } }` on ALL projects in a round, fetching complete project records with nested assignments and evaluations. For a round with 130 projects and 3+ evaluations each, this fetches 390+ evaluation records with full project data.
**Impact**: Significant memory usage and query time. The response payload is very large even though only scores are needed for ranking.
**Recommendation**: Use a raw SQL aggregate query or Prisma's `select` to only fetch the fields needed (project id, title, evaluation scores). Consider pre-computing rankings or caching results.
---
#### QO-2: getTags fetches all projects to extract unique tags (Medium) — **DONE**
**Files**: `src/server/routers/project.ts:580-599`
The `getTags` query fetches ALL projects with `select: { tags: true }` then deduplicates in JavaScript. For 100+ projects, this is inefficient.
**Impact**: Unnecessary data transfer and memory usage.
**Recommendation**: Use Prisma's `$queryRaw` with `SELECT DISTINCT unnest(tags) FROM "Project"` or PostgreSQL's `array_agg(DISTINCT ...)` to get unique tags at the database level.
---
#### QO-3: Project.list includes full files array for every project (Medium) — **DONE**
**Files**: `src/server/routers/project.ts:133-134`
The project list query includes `files: true`, fetching all file records for every project in the paginated list. Most list views only need to know whether files exist, not the full file details.
**Impact**: Over-fetching data, larger payloads, slower queries.
**Recommendation**: Use `_count: { select: { files: true } }` or `files: { select: { id: true, fileType: true } }` instead of `files: true`.
---
#### QO-4: Sequential notification sends in bulk operations (Medium) — **DONE**
**Files**: `src/server/routers/assignment.ts:476-490`, `src/server/routers/user.ts:559-596`
In `assignment.bulkCreate` and `user.bulkCreate`, notifications are sent sequentially in a `for` loop with `await createNotification(...)`. Each `createNotification` call also queries the database for email settings and user preferences.
**Impact**: Bulk operations are O(n) in database queries for notifications, significantly slowing down bulk assignment/invitation flows.
**Recommendation**: Use `createBulkNotifications` (which already exists and uses `createMany`) instead of looping with `createNotification`. Batch the email checks as well.
---
#### QO-5: Export queries fetch unbounded data (Medium) — **DONE**
**Files**: `src/server/routers/export.ts:16-34, 106-118`
Export queries for evaluations and project scores have no pagination and no limit (except audit logs with `take: 10000`). For large rounds, this could return thousands of records in a single response.
**Impact**: Memory pressure on the server, potential timeout for large exports.
**Recommendation**: Implement streaming or chunked export, or add a reasonable limit with warning to the user.
---
#### QO-6: N+1 in mentor.getSuggestions (Medium) — **DONE**
**Files**: `src/server/routers/mentor.ts:50-78`
After getting AI suggestions, each mentor is fetched individually in a `Promise.all(suggestions.map(...))` loop. Each iteration makes a separate `user.findUnique` query.
**Impact**: If there are 5 suggestions, this makes 5 additional database queries.
**Recommendation**: Fetch all needed mentors in a single `user.findMany({ where: { id: { in: mentorIds } } })` query, then map results.
---
#### QO-7: Audit log creation not batched or fire-and-forget (Low) — **DONE** (migrated to logAudit utility with try-catch)
**Files**: All routers
Every mutation `await`s the audit log creation, meaning the user's response is delayed by audit log write time. Audit logs are non-critical for the operation's success.
**Recommendation**: Consider making audit log writes fire-and-forget (don't await) or use a background queue. Alternatively, batch audit logs within transactions where possible.
---
### Data Integrity
#### DI-1: Evaluation submission and assignment update are not in a transaction (High) — **DONE**
**Files**: `src/server/routers/evaluation.ts:200-213`
The `evaluation.submit` mutation updates the evaluation status to `SUBMITTED` and then separately updates the assignment's `isCompleted` to `true`. These two operations should be atomic - if the second fails, the evaluation is marked submitted but the assignment still shows as incomplete.
**Impact**: Inconsistent state between evaluation and assignment records.
**Recommendation**: Wrap both updates in a `$transaction`:
```typescript
await ctx.prisma.$transaction([
ctx.prisma.evaluation.update({ where: { id }, data: { ...data, status: 'SUBMITTED', submittedAt: now } }),
ctx.prisma.assignment.update({ where: { id: evaluation.assignmentId }, data: { isCompleted: true } }),
])
```
---
#### DI-2: Project create + audit log not in a transaction (Medium) — **DONE**
**Files**: `src/server/routers/project.ts:299-323`, and similar patterns across all routers
Across all routers, the pattern is: create/update entity, then create audit log as a separate operation. If the audit log creation fails, the entity operation succeeds but the audit trail has a gap.
**Impact**: Missing audit entries for successful operations.
**Recommendation**: For critical operations (especially ones involving evaluation submissions, status changes, and user modifications), wrap entity changes and audit log creation in a single transaction. For less critical operations, fire-and-forget the audit log.
---
#### DI-3: bulkUpdateStatus does not check project ownership before notification (Medium) — **DONE**
**Files**: `src/server/routers/project.ts:620-742`
The `bulkUpdateStatus` mutation uses `updateMany` (which doesn't return updated records) and then separately fetches projects by ID to send notifications. If some IDs don't match the `roundId` filter, the notification list may not match the actually-updated projects.
**Impact**: Notifications could be sent for projects that weren't actually updated.
**Recommendation**: Fetch the matching projects before the update to ensure consistency between updates and notifications, or use the `updated.count` to validate.
---
#### DI-4: Assignment uniqueness constraint may be bypassed by skipDuplicates (Low) — **DONE**
**Files**: `src/server/routers/assignment.ts:427-434`
The `bulkCreate` mutation uses `createMany({ skipDuplicates: true })`. While this prevents constraint violations, it silently drops assignments that already exist. The returned count may not reflect what the user intended.
**Impact**: Users may not realize some assignments were skipped.
**Recommendation**: Before `createMany`, check which assignments already exist and report the skip count to the user (similar to how `user.bulkCreate` handles it).
---
#### DI-5: No foreign key constraint on SpecialAward.winnerOverriddenBy (Low) — **DONE**
**Files**: `prisma/schema.prisma:1213`
The `winnerProjectId` field has a relation to `Project` with `onDelete: SetNull`, which is appropriate. However, `winnerOverriddenBy` is just a `String?` with no relation to `User`, meaning there's no referential integrity check.
**Recommendation**: Add a relation from `winnerOverriddenBy` to `User` to ensure referential integrity.
---
### Performance
#### PF-1: Large transaction in filtering.executeRules (High) — **DONE**
**Files**: `src/server/routers/filtering.ts:503-531`
The `executeRules` mutation creates a transaction with one `upsert` per project. For 130 projects, this is 130 sequential upserts in a single transaction, holding database locks for the duration.
**Impact**: Long-running transaction that blocks other operations on the `FilteringResult` table.
**Recommendation**: Batch the upserts into groups of 20-30, or use `createMany`/`updateMany` where possible. Alternatively, use `$executeRawUnsafe` with a bulk `INSERT ... ON CONFLICT` statement.
---
#### PF-2: Description field text search uses ILIKE without text index (Medium) — **DEFERRED** (requires PostgreSQL full-text search extension)
**Files**: `src/server/routers/project.ts:111-117`
Project search uses `{ description: { contains: search, mode: 'insensitive' } }` which translates to `ILIKE '%search%'`. The `description` field is `@db.Text` (unlimited length). Without a text index, this performs a full table scan on potentially large text fields.
**Impact**: Slow search queries as project count grows, especially with long descriptions.
**Recommendation**: Add PostgreSQL full-text search (`tsvector`/`tsquery`) via a generated column and GIN index, or use Prisma's full-text search extension. Alternatively, limit search to `title` and `teamName` only for list views.
---
#### PF-3: Assignment stats query makes 5 separate database calls (Low) — **DONE**
**Files**: `src/server/routers/assignment.ts:530-554`
The `getStats` query runs 4 parallel queries via `Promise.all` plus a separate `findUniqueOrThrow`. While parallel, this is still 5 database round-trips.
**Recommendation**: Consolidate into fewer queries. The `projectCoverage` query already returns project data that could provide the total projects count, eliminating one query.
---
### Positive Patterns Observed
1. **Consistent use of `@@unique` composite constraints**: The schema properly uses composite unique constraints (`@@unique([userId, projectId, roundId])` on Assignment, `@@unique([awardId, projectId])` on AwardEligibility, etc.) to prevent duplicate records at the database level.
2. **Good use of `select` to limit data fetching**: Many queries use `select` clauses to fetch only needed fields (e.g., `user.me`, `user.list`), reducing payload sizes.
3. **`Promise.all` for parallel queries**: List queries consistently use `Promise.all([findMany, count])` for pagination, reducing latency.
4. **RBAC enforced at query level**: Jury members are restricted to their assigned projects through `where` clause filtering, not just middleware checks.
5. **Proper Zod validation**: All tRPC inputs use comprehensive Zod schemas with min/max constraints, enums, and proper typing.
6. **Consistent audit logging**: Nearly every mutation creates an audit log entry with context (userId, action, entityType, entityId, detailsJson).
7. **Background job pattern**: Long-running AI operations (filtering, assignment) use a job table with status tracking and progress callbacks, with fire-and-forget execution.
8. **Cascade deletes on relations**: The schema consistently uses `onDelete: Cascade` for owned relations and `onDelete: SetNull` for references, which is appropriate.
9. **Good use of JsonB for flexible data**: The use of `@db.JsonB` for criteria, settings, and metadata allows schema flexibility without migrations.
10. **Pagination implemented consistently**: List endpoints consistently implement cursor or offset-based pagination with `page`/`perPage` parameters and return total counts.
---
### Priority Summary
| Priority | Count | Key Issues |
|----------|-------|------------|
| **High** | 4 | Missing transaction on evaluation submit (DI-1), large filtering transaction (PF-1), analytics over-fetching (QO-1), project.roundId nullability mismatch (SD-1) |
| **Medium** | 9 | Soft deletes not implemented (SD-3), evaluation versioning missing (SD-2), N+1 queries (QO-6), sequential notifications (QO-4), audit log growth (IX-2), export unbounded (QO-5), text search without index (PF-2), DI-2, DI-3 |
| **Low** | 7 | Redundant email index (IX-3), string status fields (SD-4, SD-5), deprecated date fields (SD-6), grace period composite index (IX-4), DI-4, DI-5, PF-3 |
---
## 4. Code Quality Review
### 4.1 Duplicate Code
#### 4.1.1 `toProjectWithRelations` duplicated across 3 AI services — **DONE**
**Files:**
- `src/server/services/ai-tagging.ts:144`
- `src/server/services/ai-filtering.ts:281`
- `src/server/services/ai-award-eligibility.ts:137`
All three files contain nearly identical `toProjectWithRelations()` functions that convert a project object to the `ProjectWithRelations` type for anonymization. The function body is the same each time: mapping fields, using `as any` casts, providing defaults for missing fields.
**Recommendation:** Extract into a shared function in `src/server/services/anonymization.ts` (which already defines `ProjectWithRelations`).
**Effort:** Quick Fix
---
#### 4.1.2 AI service batch processing boilerplate — **DEFERRED** (large refactor, high risk for no behavioral change)
**Files:**
- `src/server/services/ai-assignment.ts:273-377`
- `src/server/services/ai-filtering.ts:425-565`
- `src/server/services/ai-award-eligibility.ts:278-368`
- `src/server/services/mentor-matching.ts:202-337`
All four AI services follow an identical pattern:
1. Get OpenAI client, check if null -> fallback
2. Get configured model
3. Anonymize data
4. Validate anonymization
5. Loop over batches, call `processXxxBatch()`
6. Track total tokens
7. Catch errors, classify with `classifyAIError`, log with `logAIError`, log usage with `logAIUsage`
8. Fallback to algorithm
Each batch processor also repeats: build params -> call API -> extract usage -> log usage -> parse JSON -> deanonymize -> handle parse errors with identical error logging.
**Recommendation:** Create a generic `processAIBatches()` utility that handles the shared orchestration, accepting callbacks for prompt building and result parsing.
**Effort:** Large Refactor
---
#### 4.1.3 Avatar and Logo routers are structural clones — **DONE**
**Files:**
- `src/server/routers/avatar.ts` (187 lines)
- `src/server/routers/logo.ts` (196 lines)
Both routers have identical structure with 4 endpoints each (`getUploadUrl`, `confirmUpload`, `getUrl`, `delete`) that follow the same pattern:
- Validate image type
- Generate storage key
- Get provider
- Upload/download/delete with provider
- Update database record
- Create audit log
The only differences are: entity type (User vs Project), field names (profileImageKey vs logoKey), and auth level (protectedProcedure vs adminProcedure).
**Recommendation:** Create a generic `createImageUploadRouter()` factory that accepts entity config.
**Effort:** Medium
---
#### 4.1.4 Navigation components (jury, mentor, observer) are near-duplicates — **DONE**
**Files:**
- `src/components/layouts/jury-nav.tsx`
- `src/components/layouts/mentor-nav.tsx`
- `src/components/layouts/observer-nav.tsx`
All three components share the same structure: mobile hamburger menu, desktop nav bar, user avatar dropdown with settings/sign-out, notification bell. They differ only in the navigation links array and the user prop interface name.
**Recommendation:** Create a shared `RoleNav` component that accepts a `navigation` array and role-specific config as props.
**Effort:** Medium
---
#### 4.1.5 Expertise matching logic duplicated between AI assignment fallback and smart assignment — **SKIPPED** (different scoring ranges 0-1 vs 0-40, consolidating would change algorithm behavior)
**Files:**
- `src/server/services/ai-assignment.ts:514-534` (`calculateExpertiseScore`)
- `src/server/services/smart-assignment.ts:140-163` (`calculateTagOverlapScore`)
Both implement tag-based expertise matching with slight variations. The fallback in `ai-assignment.ts` uses set intersection, while `smart-assignment.ts` uses weighted confidence scores. The mentor-matching algorithmic fallback (`src/server/services/mentor-matching.ts:374-444`) also has its own keyword matching implementation.
**Recommendation:** Consolidate expertise scoring into a single utility with configurable weighting.
**Effort:** Medium
---
#### 4.1.6 Audit logging: two competing patterns — **DONE**
**Files:**
- `src/server/utils/audit.ts` - Shared `logAudit()` utility (wraps in try-catch)
- Used in: `src/server/routers/filtering.ts`, `src/server/routers/specialAward.ts`
- 19 other router files use inline `ctx.prisma.auditLog.create()` directly (79 total occurrences)
Most routers create audit logs inline without try-catch, meaning an audit failure could break the operation. The `logAudit` utility exists specifically to prevent this but is only used in 2 of 21 routers.
**Recommendation:** Migrate all 79 inline audit log calls to use the `logAudit` utility for consistent error handling. Or add audit logging as tRPC middleware.
**Effort:** Medium
---
#### 4.1.7 Batch tagging logic duplicated between service and router — **DONE** (dead service functions removed)
**Files:**
- `src/server/services/ai-tagging.ts:456-551` (`batchTagProjects`)
- `src/server/services/ai-tagging.ts:558-655` (`batchTagProgramProjects`)
- `src/server/routers/tag.ts:20-170` (`runTaggingJob`)
`batchTagProjects` and `batchTagProgramProjects` in the service are nearly identical (differ only in the Prisma `where` clause). The `runTaggingJob` in the router reimplements the same loop with progress tracking.
**Recommendation:** The service functions (`batchTagProjects`, `batchTagProgramProjects`) appear to be dead code since the router uses `runTaggingJob` directly. Remove the unused service functions and consolidate into a single batch function with a flexible query parameter.
**Effort:** Medium
---
### 4.2 Dead Code
#### 4.2.1 `getObjectInfo` function in minio.ts is never called — **DONE**
**File:** `src/lib/minio.ts:115-120`
The `getObjectInfo` function is exported but never imported or used anywhere in the codebase.
**Recommendation:** Remove.
**Effort:** Quick Fix
---
#### 4.2.2 `isValidImageSize` function in storage/index.ts is never called — **DONE**
**File:** `src/lib/storage/index.ts:136-138`
Exported but never imported anywhere. Image size validation is not performed server-side.
**Recommendation:** Remove, or actually use it in the avatar/logo upload flows.
**Effort:** Quick Fix
---
#### 4.2.3 `clearStorageProviderCache` is never called — **DONE**
**File:** `src/lib/storage/index.ts:85-88`
This function is defined to clear the cached storage provider when settings change, but it is never called from the settings router or anywhere else. This means changing the storage provider in settings has no effect until the server restarts.
**Recommendation:** Call this function from the settings router when `storage_provider` is updated.
**Effort:** Quick Fix
---
#### 4.2.4 `deleteObject` in minio.ts is only used indirectly — **DONE**
**File:** `src/lib/minio.ts:93-98`
Only referenced via the S3 storage provider (`src/lib/storage/s3-provider.ts`). The direct export from minio.ts is never imported directly by any router or service.
**Recommendation:** Consider making it non-exported (internal to the module) since it's only used by the S3 provider.
**Effort:** Quick Fix
---
#### 4.2.5 Deprecated batch tag endpoints still exposed — **DONE**
**File:** `src/server/routers/tag.ts:763-814`
Two endpoints are marked `@deprecated` (`batchTagProjects`, `batchTagProgramProjects`) but still included in the router. They duplicate the logic of `startTaggingJob`.
**Recommendation:** Check if any frontend code calls these deprecated endpoints. If not, remove them.
**Effort:** Quick Fix
---
#### 4.2.6 `batchTagProjects` and `batchTagProgramProjects` service functions likely unused — **DONE**
**Files:**
- `src/server/services/ai-tagging.ts:456-551`
- `src/server/services/ai-tagging.ts:558-655`
These two exported functions are no longer called by the tag router (which uses `runTaggingJob` instead). No other files import them.
**Recommendation:** Remove both functions.
**Effort:** Quick Fix
---
#### 4.2.7 `twilio` npm package is never imported — **DONE**
**File:** `package.json:85`
The `twilio` package (v5.4.0) is listed as a dependency, but the Twilio WhatsApp provider (`src/lib/whatsapp/twilio-provider.ts`) uses raw `fetch()` calls to the Twilio API instead of the SDK. The SDK is never imported anywhere.
**Recommendation:** Remove `twilio` from dependencies to reduce bundle size and install footprint.
**Effort:** Quick Fix
---
### 4.3 Redundant Code
#### 4.3.1 Two storage abstraction layers: minio.ts and storage/ — **DONE**
**Files:**
- `src/lib/minio.ts` - Direct MinIO client with helper functions
- `src/lib/storage/` - Provider-based abstraction (S3Provider wraps minio.ts, LocalProvider)
Some routers use `minio.ts` directly (`file.ts`, `partner.ts`, `learningResource.ts`, `applicant.ts`) while others use the storage abstraction (`avatar.ts`, `logo.ts`). This creates inconsistency: files uploaded through direct minio calls won't work if the admin switches to local storage.
**Recommendation:** Migrate all direct `@/lib/minio` imports to use the `@/lib/storage` abstraction for consistent provider-agnostic file handling.
**Effort:** Medium
---
#### 4.3.2 Debug console.log statements in user router — **DONE**
**File:** `src/server/routers/user.ts:246,256`
Debug logging for a simple user fetch query:
```
console.log('[user.get] Fetching user:', input.id)
console.log('[user.get] Found user:', user.email)
```
These appear to be leftover development debug statements, not structured logging.
**Recommendation:** Remove.
**Effort:** Quick Fix
---
#### 4.3.3 Excessive console.log in AI services — **DONE** (structured logger implemented)
**Files:**
- `src/server/services/ai-tagging.ts` - 22 console.log statements
- `src/server/services/ai-filtering.ts` - 5 console.log statements
- `src/server/services/ai-assignment.ts` - 5 console.log statements
- `src/server/services/ai-award-eligibility.ts` - 5 console.log statements
- `src/server/services/mentor-matching.ts` - 4 console.log statements
- `src/server/routers/tag.ts` - 10 console.log statements
Total: ~51 console.log/warn/error statements across AI services. While useful for debugging, production code should use structured logging with configurable log levels.
**Recommendation:** Implement a simple logger utility with configurable levels (debug, info, warn, error) and replace all console.log calls.
**Effort:** Medium
---
### 4.4 Unfinished Code
#### 4.4.1 TODO: Send invitation email to new team member — **DONE**
**File:** `src/server/routers/applicant.ts:659`
```typescript
// TODO: Send invitation email to the new team member
```
The team member addition flow creates the team member record but never sends an invitation email.
**Recommendation:** Implement email notification using the existing notification service.
**Effort:** Medium
---
#### 4.4.2 `aiBoost` field always returns 0 — **DONE** (removed unused field)
**File:** `src/server/services/smart-assignment.ts:27,370`
The `ScoreBreakdown` type includes an `aiBoost` field (described as "Reserved: 0-5 points (future AI boost)" in the comment), but it's always set to `0` in all score calculations.
**Recommendation:** Either implement the AI boost feature or remove the field to avoid confusion.
**Effort:** Quick Fix (to remove) / Large Refactor (to implement)
---
### 4.5 Unused Dependencies — **ALL DONE**
| Package | Evidence | Recommendation | Status |
|---------|----------|----------------|--------|
| `twilio` (v5.4.0) | Never imported; Twilio provider uses raw `fetch()` | Remove from package.json | **DONE** |
| `autoprefixer` (v10.4.20) | Only referenced in postcss config; Tailwind v4 includes autoprefixer | Verify and potentially remove | **DONE** |
| `@types/leaflet` (v1.9.21) | Listed in `dependencies` not `devDependencies` | Move to devDependencies | **DONE** |
---
### 4.6 Summary
| Category | Count | Quick Fix | Medium | Large |
|----------|-------|-----------|--------|-------|
| Duplicate Code | 7 | 0 | 4 | 3 |
| Dead Code | 7 | 7 | 0 | 0 |
| Redundant Code | 3 | 2 | 1 | 0 |
| Unfinished Code | 2 | 1 | 1 | 0 |
| Unused Dependencies | 3 | 3 | 0 | 0 |
| **Total** | **22** | **13** | **6** | **3** |
**Priority recommendations (highest impact, lowest effort):**
1. Remove unused `twilio` dependency (saves ~10MB in node_modules)
2. Remove dead code: `getObjectInfo`, `isValidImageSize`, deprecated batch endpoints, unused batch service functions
3. Call `clearStorageProviderCache` when storage settings change (bug fix)
4. Extract shared `toProjectWithRelations` to eliminate 3-way duplication
5. Migrate direct `@/lib/minio` imports to `@/lib/storage` for consistency
---
## 2. Security Review
**Reviewer**: Security Reviewer Agent
**Date**: 2026-02-05
**Scope**: Authentication, authorization, input validation, file security, rate limiting, CSRF, data privacy, password security, API routes, injection prevention, XSS, audit logging, session security, environment variables, account lockout
---
### Critical Findings
#### CRIT-1: Path Traversal Bypass in Local Storage Provider — **DONE**
**Affected file**: `src/lib/storage/local-provider.ts:65-68`
The path traversal sanitization is insufficient. It only strips literal `..` sequences but does not account for encoded variants or alternative traversal techniques on Windows. Additionally, `path.join()` can resolve `..` segments inserted via other mechanisms.
```typescript
private getFilePath(key: string): string {
const sanitizedKey = key.replace(/\.\./g, '').replace(/^\//, '')
return path.join(this.basePath, sanitizedKey)
}
```
**Impact**: An attacker with a valid signed URL (or who can manipulate the `key` parameter) could potentially read arbitrary files from the server filesystem.
**Recommendation**: After constructing the full path with `path.resolve()`, verify it still starts with the expected base directory:
```typescript
const resolved = path.resolve(this.basePath, sanitizedKey)
if (!resolved.startsWith(path.resolve(this.basePath))) {
throw new Error('Invalid file path')
}
```
---
#### CRIT-2: Timing-Unsafe Signature Comparison — **DONE**
**Affected file**: `src/lib/storage/local-provider.ts:56-63`
The HMAC signature verification uses `===` string comparison, which is vulnerable to timing attacks. An attacker could theoretically determine the correct HMAC byte-by-byte by measuring response times.
```typescript
return signature === expectedSignature && expiresAt > Date.now() / 1000
```
**Impact**: In theory, an attacker could forge valid pre-signed URLs by progressively guessing the HMAC signature. While practical exploitation is difficult over the network, it remains a cryptographic best-practice violation.
**Recommendation**: Use `crypto.timingSafeEqual()` for HMAC comparison:
```typescript
import { timingSafeEqual } from 'crypto'
const sigBuffer = Buffer.from(signature, 'hex')
const expectedBuffer = Buffer.from(expectedSignature, 'hex')
return sigBuffer.length === expectedBuffer.length
&& timingSafeEqual(sigBuffer, expectedBuffer)
&& expiresAt > Date.now() / 1000
```
---
#### CRIT-3: Email/Password Change API Routes Lack Proper Authentication — **DONE**
**Affected files**: `src/app/api/email/verify-credentials/route.ts`, `src/app/api/email/change-password/route.ts`
These API routes accept `POST` requests with email and password credentials but have no session-based authentication verifying the caller. While the middleware `authorized` callback blocks unauthenticated users from non-public paths, these routes do not verify the authenticated user matches the email being acted upon. An authenticated user could verify credentials or change the Poste.io mailbox password of any `@monaco-opc.com` email address.
The change-password route at `src/app/api/email/change-password/route.ts:97-109` sends the new password in plaintext to the Poste.io admin API using hardcoded admin credentials.
**Impact**: An authenticated user could potentially change another user's email password. Rate limiting (3-5 attempts per 15 minutes) slows but does not prevent this.
**Recommendation**: Add NextAuth session checks to both API routes. Verify that the authenticated user's email matches the email being acted upon.
---
### High Severity Findings
#### HIGH-1: In-Memory Rate Limiting Does Not Scale — **DEFERRED** (requires Redis infrastructure)
**Affected files**: `src/lib/rate-limit.ts`, `src/lib/auth.ts:12`
Both the rate limiter and the account lockout tracker use in-memory `Map` objects. In a multi-instance deployment (e.g., multiple Docker replicas behind a load balancer), rate limits and lockouts are per-instance, not global.
```typescript
const failedAttempts = new Map<string, { count: number; lockedUntil: number }>()
```
**Impact**: An attacker could bypass rate limiting by having requests routed to different instances. Account lockout after 5 failed attempts would not work reliably across instances. This is acknowledged in the rate-limit.ts comment but still represents a deployment risk.
**Recommendation**: Move to Redis-based rate limiting and lockout tracking before deploying with multiple instances. If running single-instance only, document this constraint clearly.
---
#### HIGH-2: Health Check Endpoint Leaks Database Error Details — **DONE**
**Affected file**: `src/app/api/health/route.ts:29`
The health endpoint returns the actual error message when the database is disconnected:
```typescript
error: error instanceof Error ? error.message : 'Unknown error',
```
**Impact**: Database connection errors may contain hostnames, ports, connection strings, or other internal infrastructure details that aid attackers in reconnaissance.
**Recommendation**: Log the full error server-side but return only a generic status to the client. If the health endpoint is meant for internal monitoring only, restrict access via network configuration or authentication.
---
#### HIGH-3: Local Storage Fallback Secret Key — **DONE**
**Affected file**: `src/lib/storage/local-provider.ts:6`
```typescript
const SECRET_KEY = process.env.NEXTAUTH_SECRET || 'local-storage-secret'
```
If `NEXTAUTH_SECRET` is not set, the signing key falls back to a hardcoded value. While `NEXTAUTH_SECRET` should always be set in production, this fallback is dangerous if accidentally deployed without it.
**Impact**: An attacker who knows the fallback value can forge pre-signed file download/upload URLs, bypassing all file access controls.
**Recommendation**: Remove the fallback or throw an error at startup if `NEXTAUTH_SECRET` is not set. Never use hardcoded cryptographic secrets.
---
#### HIGH-4: MinIO Client Defaults to Insecure Credentials — **DONE**
**Affected file**: `src/lib/minio.ts:23-24`
```typescript
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
```
**Impact**: If environment variables are not set, the client uses default MinIO credentials (`minioadmin/minioadmin`). Combined with a misconfigured or exposed MinIO instance, this could grant full access to all stored files including jury evaluation documents and project submissions.
**Recommendation**: Fail loudly in production when these environment variables are not set instead of falling back to defaults.
---
#### HIGH-5: Application Submission Creates Users Without Verification — **DONE** (added rate limiting)
**Affected file**: `src/server/routers/application.ts:148-341`
The `application.submit` route uses `publicProcedure` (no authentication required) and creates new `User` records with the `APPLICANT` role, plus `TeamMember` records, based entirely on user-supplied email addresses. Any external user can:
1. Create arbitrary user accounts with the `APPLICANT` role
2. Create team member records linking arbitrary email addresses
3. Pollute the user table with fake data
While the APPLICANT role has limited permissions, there is no CAPTCHA, no email verification, and the only rate limit is the global tRPC limit of 100 requests/minute per IP.
**Impact**: User table pollution, spam applications, potential abuse of the notification system (applicants receive in-app notifications).
**Recommendation**: Add CAPTCHA/reCAPTCHA verification to the submission endpoint. Implement a stricter rate limit specific to application submissions. Consider requiring email verification before creating user accounts and team memberships.
---
### Medium Severity Findings
#### MED-1: Password Policy Does Not Require Special Characters — **DONE**
**Affected file**: `src/lib/password.ts:34-56`
The password validation requires only 8 characters, one uppercase, one lowercase, and one digit. Special characters are not required.
**Impact**: Passwords without special characters are more susceptible to dictionary and brute-force attacks.
**Recommendation**: Consider requiring at least one special character, or increase the minimum length to 12 characters. Also consider checking against common password lists (e.g., "Have I Been Pwned" API or a local blocklist of the top 10,000 passwords).
---
#### MED-2: Audit Logging Failures Are Silently Swallowed — **DONE**
**Affected files**: `src/server/utils/audit.ts:29-32`, `src/lib/auth.ts:91,147`, and many router files
Audit log failures are caught and silently ignored throughout the codebase:
```typescript
await prisma.auditLog.create({ ... }).catch(() => {})
```
**Impact**: If the audit system fails (e.g., database connection issues, disk full), critical security events (login attempts, role changes, data exports) will not be recorded. This violates the stated requirement of an "immutable audit log."
**Recommendation**: At minimum, log audit failures to a secondary logging system (e.g., file-based logger or error monitoring service like Sentry). Consider implementing a write-ahead buffer or retry mechanism for audit entries.
---
#### MED-3: File Upload Has No Content-Type or Extension Validation — **DONE**
**Affected file**: `src/server/routers/file.ts:75-125`
The `getUploadUrl` procedure validates the `fileType` enum (EXEC_SUMMARY, PRESENTATION, VIDEO, OTHER) and accepts a `mimeType` string, but does not verify that the MIME type matches the file extension, nor does it block dangerous file types (e.g., `.exe`, `.sh`, `.php`, `.html`).
The local storage upload route at `src/app/api/storage/local/route.ts:111-124` accepts any content-type from the request header without validation.
**Impact**: An admin user could upload executable or HTML files. If these files are later served with their original content-type, this could lead to stored XSS or other browser-based attacks when other users download them.
**Recommendation**: Implement server-side MIME type validation and file extension allowlisting. Serve uploaded files with `Content-Disposition: attachment` headers and sanitized content types.
---
#### MED-4: File Deletion Does Not Remove Storage Objects — **DONE**
**Affected file**: `src/server/routers/file.ts:143-171`
When a file record is deleted, only the database entry is removed. The actual file in MinIO or local storage is not deleted:
```typescript
// Note: Actual MinIO deletion could be done here or via background job
// For now, we just delete the database record
```
**Impact**: Orphaned files accumulate in storage. Previously-issued pre-signed URLs remain valid until their expiry. This has data retention/GDPR compliance implications.
**Recommendation**: Implement storage object deletion alongside database record deletion, or create a background cleanup job for orphaned files.
---
#### MED-5: IP Address Extraction Relies on Spoofable Header — **DEFERRED** (requires Nginx proxy configuration)
**Affected files**: `src/server/context.ts:13`, `src/app/api/trpc/[trpc]/route.ts:13-18`, `src/app/api/auth/[...nextauth]/route.ts:8-12`
Client IP is extracted from the `x-forwarded-for` header:
```typescript
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
```
**Impact**: If the application is not behind a properly configured reverse proxy, clients can spoof their IP address by setting the `x-forwarded-for` header. This bypasses IP-based rate limiting and poisons audit log entries with false IP addresses.
**Recommendation**: Configure the application to only trust `x-forwarded-for` from known proxy IPs. In production with Nginx, ensure the proxy overwrites (not appends to) this header and strips any client-supplied values.
---
#### MED-6: Public Email Availability Check Enables Enumeration — **DONE**
**Affected file**: `src/server/routers/application.ts:346-367`
The `checkEmailAvailability` endpoint is a public procedure that reveals whether a specific email has already submitted an application for a given round:
```typescript
return { available: !existing, message: existing ? 'An application with this email...' : null }
```
**Impact**: An attacker can enumerate which emails have submitted applications for specific rounds, potentially revealing participation in the ocean protection challenge.
**Recommendation**: Consider removing this endpoint or requiring authentication. If it is needed for UX, implement stricter rate limiting and CAPTCHA.
---
#### MED-7: Assignment Job Status Accessible to All Authenticated Users — **DONE**
**Affected file**: `src/server/routers/assignment.ts:1041-1061`
The `getAIAssignmentJobStatus` endpoint uses `protectedProcedure` instead of `adminProcedure`:
```typescript
getAIAssignmentJobStatus: protectedProcedure
.input(z.object({ jobId: z.string() }))
```
**Impact**: Any authenticated user (including jury members, observers, applicants) can query the status of AI assignment jobs if they know or guess a job ID. While job IDs are UUIDs, this exposes internal platform operations unnecessarily.
**Recommendation**: Change to `adminProcedure` to restrict to admin users only.
---
#### MED-8: PII Logging in User Router — **DONE**
**Affected file**: `src/server/routers/user.ts:246,256`
Debug logging statements expose user data in server logs:
```typescript
console.log('[user.get] Fetching user:', input.id)
console.log('[user.get] Found user:', user.email)
```
**Impact**: User IDs and email addresses are written to server logs. Under GDPR, log files containing PII must be treated as personal data and properly managed (retention, access controls, etc.).
**Recommendation**: Remove debug console.log statements or replace with a structured logger that respects log levels and redacts PII.
---
### Low Severity Findings
#### LOW-1: Session Configuration Could Be Hardened — **DONE**
**Affected file**: `src/lib/auth.config.ts:88-90`
The session uses JWT strategy with a default 24-hour maxAge. No explicit cookie configuration is visible.
```typescript
session: {
strategy: 'jwt',
maxAge: parseInt(process.env.SESSION_MAX_AGE || '86400'),
},
```
**Impact**: NextAuth v5 sets secure cookie defaults, but explicit configuration is better for defense-in-depth. A 24-hour session may be too long for admin users.
**Recommendation**: Explicitly set cookie options: `httpOnly: true`, `secure: true` (production), `sameSite: 'lax'`. Consider shorter session lifetimes for admin roles.
---
#### LOW-2: Export Endpoint Allows Large Data Fetches — **DONE** (reduced to 5000)
**Affected file**: `src/server/routers/export.ts:266`
```typescript
take: 10000, // Limit export to 10k records
```
**Impact**: Large exports could cause memory pressure or slow response times. Admin-only access mitigates the risk.
**Recommendation**: Consider pagination or streaming exports for large datasets.
---
#### LOW-3: Project List Allows Up to 5000 Items Per Page — **DONE** (reduced to 200)
**Affected file**: `src/server/routers/project.ts:58`
```typescript
perPage: z.number().int().min(1).max(5000).default(20),
```
**Impact**: A request for 5000 projects with full includes could cause significant memory usage and slow responses.
**Recommendation**: Reduce the maximum to a reasonable value (e.g., 200) or implement cursor-based pagination for large result sets.
---
#### LOW-4: No CSRF Protection on Custom API Routes — **DONE**
**Affected files**: `src/app/api/email/verify-credentials/route.ts`, `src/app/api/email/change-password/route.ts`, `src/app/api/storage/local/route.ts`
These custom API routes (outside of tRPC and NextAuth) accept POST/PUT requests without explicit CSRF token validation. While the Same-Origin Policy and Content-Type restrictions provide some protection, explicit CSRF tokens would add defense-in-depth.
**Impact**: Low risk due to browser same-origin restrictions, but a defense-in-depth concern.
**Recommendation**: Consider adding CSRF token validation for custom API routes, especially those that modify state (password change, file upload).
---
#### LOW-5: Invite Token Validation Has No Specific Rate Limit — **N/A** (token entropy sufficient, no action needed)
**Affected file**: `src/server/routers/user.ts:45-69`
The `validateInviteToken` is a public procedure protected only by the global tRPC rate limit (100/min per IP). With 256 bits of entropy in the tokens, brute-force is computationally infeasible.
**Impact**: Negligible due to the token entropy. Listed for completeness.
**Recommendation**: No immediate action needed. The existing token entropy is sufficient.
---
### Informational Findings (Positive Security Practices)
#### INFO-1: Comprehensive Audit Logging — **N/A**
The platform implements thorough audit logging across all critical operations: user management, authentication, evaluations, assignments, file access, exports, and settings changes. Each audit entry includes userId, action, entityType, entityId, details, IP address, and user agent.
#### INFO-2: Well-Designed RBAC Architecture — **N/A**
The tRPC middleware-based RBAC system is well-designed with clear role hierarchies (`publicProcedure`, `protectedProcedure`, `adminProcedure`, `superAdminProcedure`, `juryProcedure`, `mentorProcedure`). Role checks are enforced at the procedure level, with additional ownership checks in individual handlers.
#### INFO-3: No SQL Injection Risk — **N/A**
The codebase exclusively uses Prisma ORM for database access. The only raw query found is the health check's `SELECT 1`. No user input is interpolated into raw SQL anywhere in the application.
#### INFO-4: No XSS via dangerouslySetInnerHTML — **N/A**
No usage of `dangerouslySetInnerHTML` was found in the codebase. React's default escaping provides XSS protection for rendered content.
#### INFO-5: Environment Variables Properly Gitignored — **N/A**
All `.env` variants (`.env`, `.env.local`, `.env.development.local`, `.env.test.local`, `.env.production.local`, `docker/.env`) are listed in `.gitignore`. No hardcoded secrets were found in the source code beyond the noted fallback defaults.
#### INFO-6: Thorough Data Anonymization for AI — **N/A**
The anonymization service (`src/server/services/anonymization.ts`) comprehensively strips PII (emails, phones, URLs, SSNs, IP addresses) before sending data to OpenAI. GDPR compliance validation is enforced before every AI call. Both basic (assignment) and enhanced (filtering/awards) anonymization paths are implemented with validation.
#### INFO-7: CSRF Protection via tRPC Content-Type — **N/A**
tRPC uses `application/json` content type which triggers CORS preflight for cross-origin requests, providing natural CSRF protection. NextAuth routes have built-in CSRF token protection. No permissive CORS headers are configured.
#### INFO-8: Strong Password Hashing — **N/A**
bcrypt with 12 salt rounds provides strong password hashing. Account lockout after 5 failed attempts with a 15-minute lockout is implemented. Password reset does not reveal whether an email exists (anti-enumeration pattern).
#### INFO-9: Jury Data Isolation — **N/A**
Jury members can only see projects they are assigned to. This is enforced both at the query level (project list endpoint adds `assignments: { some: { userId: ctx.user.id } }`) and via explicit assignment checks in get/evaluation endpoints.
#### INFO-10: Rate Limiting Coverage — **N/A**
Rate limiting is applied to: all tRPC endpoints (100 requests/min), auth POST endpoints (10/min), email verification (5 per 15 min), and email password change (3 per 15 min). Headers expose rate limit status for client-side handling.
---
### Security Review Summary
| Severity | Count | Key Areas |
|----------|-------|-----------|
| Critical | 3 | Path traversal, timing-unsafe HMAC, unauthenticated email routes |
| High | 5 | In-memory rate limiting, error info leaks, hardcoded secrets, user creation without verification |
| Medium | 8 | Password policy, audit reliability, file validation, IP spoofing, access control gaps |
| Low | 5 | Session config, large exports, CSRF on custom routes |
| Informational | 10 | Positive practices observed |
**Overall Assessment**: The platform demonstrates strong security fundamentals with comprehensive RBAC, audit logging, proper ORM usage (no SQL injection), XSS prevention, and data anonymization for AI services. The critical issues center around the local file storage provider (path traversal bypass, timing-unsafe HMAC comparison) and the email API routes lacking proper authorization checks. The high-severity items relate to deployment concerns (in-memory rate limiting, hardcoded fallback secrets) and the unauthenticated application submission endpoint's ability to create user records without verification. Addressing the critical and high-severity items should be prioritized before production deployment with external exposure.
---
## 5. Feature Proposals & Improvements — **PARTIAL** (10 of 26 features implemented)
*Reviewed by: Feature Proposer Agent*
*Date: 2026-02-05*
This section proposes new features and improvements based on a thorough review of the platform's codebase, including all routers, services, pages, components, and data model. Proposals are organized by implementation effort and prioritized by impact.
> **Note**: This section contains feature proposals for future development. None of these are bugs or issues — they are enhancement ideas preserved as a roadmap reference.
---
### Quick Wins (Low Effort, High Impact)
#### 5.1.1 Jury Evaluation Reminders & Deadline Countdown — **DONE**
**What**: Add automated reminder notifications at configurable intervals before voting deadlines (e.g., 72h, 24h, 1h). Show a prominent countdown timer on the jury dashboard and assignment pages when deadline is approaching.
**Why**: The platform already has `REMINDER_24H` and `REMINDER_1H` notification types defined in the email templates, but there is no scheduled job or cron mechanism to actually trigger these reminders. Jury members may forget deadlines, leading to incomplete evaluations.
**Who benefits**: Jury Members, Program Admins (higher completion rates)
**Scope**: Add a scheduled task (cron job or database-polling endpoint) that checks upcoming deadlines and sends notifications. Add countdown UI component to jury dashboard. ~2-3 days.
---
#### 5.1.2 Evaluation Progress Indicator Per Project — **DONE**
**What**: On the evaluation form page (`/jury/projects/[id]/evaluate`), show a visual progress indicator (e.g., "3 of 5 criteria scored") so jury members know how far along they are before submitting.
**Why**: The current autosave system tracks partial scores, but there is no visual indicator showing the jury member how many criteria they have completed. This causes friction especially for forms with many criteria.
**Who benefits**: Jury Members
**Scope**: Frontend-only change. Add a progress bar or step indicator to the evaluation form component. ~0.5 day.
---
#### 5.1.3 Bulk Status Update for Projects — **DONE**
**What**: Allow admins to select multiple projects from the project list and change their status in bulk (e.g., mark 20 projects as SEMIFINALIST at once).
**Why**: Currently, the project router has individual update capabilities but no bulk status change. After reviewing evaluations, admins need to advance multiple projects, which is tedious one-by-one. The filtering router has `finalizeResults` for automated advancement, but there is no manual bulk option outside of filtering.
**Who benefits**: Program Admins, Super Admins
**Scope**: Add `bulkUpdateStatus` mutation to project router, add checkbox selection UI to project list page. ~1-2 days.
---
#### 5.1.4 Export Filtering Results as CSV — **DONE**
**What**: Add CSV export capability for filtering results, similar to the existing evaluation and project score exports.
**Why**: The export router currently supports evaluations, project scores, assignments, and audit logs, but not filtering results. Admins reviewing AI filtering outcomes may want to share or archive these results externally.
**Who benefits**: Program Admins
**Scope**: Add a new endpoint to the export router for filtering results. ~0.5 day.
---
#### 5.1.5 Observer Access to Reports & Analytics — **DONE**
**What**: Allow observers to view the Reports page (analytics charts and exports) in read-only mode.
**Why**: The observer dashboard (`/observer`) shows basic stats (program count, project count, juror count, evaluation completion), but observers cannot access the detailed analytics charts (score distribution, evaluation timeline, juror workload, project rankings, geographic distribution) that are on the admin Reports page. The observer role is designed for stakeholders who need visibility without edit access.
**Who benefits**: Observers (stakeholders, board members)
**Scope**: Create an `/observer/reports` page that mirrors the admin analytics tab using the existing chart components. Requires adding observer-accessible query variants for the analytics router. ~1-2 days.
---
#### 5.1.6 Conflict of Interest Declaration — **DONE**
**What**: When a jury member starts an evaluation, prompt them to declare any conflicts of interest with the project. Store the declaration and allow admins to flag/reassign if needed.
**Why**: In academic and grant judging, conflict of interest management is a standard requirement. Currently there is no mechanism for jurors to declare COI, which could undermine the integrity of the evaluation process.
**Who benefits**: Jury Members, Program Admins, platform credibility
**Scope**: Add a `conflictOfInterest` field to the Assignment or Evaluation model, add a COI declaration dialog before evaluation starts, add admin view for flagged COI. ~2-3 days.
---
### Medium-Term (Moderate Effort)
#### 5.2.1 Email Digest / Summary Notifications
**What**: Implement a daily or weekly email digest that summarizes a user's pending actions: unfinished evaluations, upcoming deadlines, new assignments, new notifications. Configurable per user (daily/weekly/off).
**Why**: The notification system sends individual emails per event, which can be noisy. A digest approach reduces email fatigue while ensuring users stay informed. The `NotificationEmailSetting` model exists but only controls per-type settings, not digest frequency.
**Who benefits**: All roles (Jury Members, Mentors, Admins)
**Scope**: Add user preference for digest frequency, create digest aggregation service, implement scheduled job to send digests. ~3-5 days.
---
#### 5.2.2 Evaluation Calibration / Norming Session Tool
**What**: Create a calibration mode where all jurors evaluate the same 2-3 "test" projects, then admins can view the variance in scores and use this to identify outlier jurors or adjust scoring guidance.
**Why**: In large jury panels (handling 130+ projects), inter-rater reliability is a common challenge. Some jurors may be consistently lenient or harsh. A calibration round helps ensure scoring consistency and is standard practice in grant review and academic conferences.
**Who benefits**: Program Admins (quality assurance), Jury Members (alignment)
**Scope**: Add a "calibration round" type, mark specific projects as calibration, create comparison analytics showing juror variance on calibration projects. ~5-7 days.
---
#### 5.2.3 Multi-Language Support (i18n)
**What**: Add internationalization support, starting with French and English. The platform is for Monaco, which is French-speaking, but the jury may be international.
**Why**: Monaco is a French-speaking principality, and many stakeholders and applicants may prefer French. The current platform is English-only. This is especially important for the public application form and applicant-facing pages.
**Who benefits**: Applicants, Jury Members, all French-speaking users
**Scope**: Integrate next-intl or similar i18n library, extract all user-facing strings, translate to French. Start with public-facing pages (application form, login, applicant portal). ~5-10 days for initial setup + French translation.
---
#### 5.2.4 Project Comparison View for Jury
**What**: Allow jury members to view 2-3 assigned projects side-by-side in a comparison view, showing key details (title, description, team, country, tags) next to each other.
**Why**: When evaluating 15-20 projects, jurors often want to compare similar projects before assigning scores. Currently they must navigate between individual project pages, losing context.
**Who benefits**: Jury Members
**Scope**: Create a new comparison page that loads multiple projects, side-by-side layout. ~3-4 days.
---
#### 5.2.5 Applicant Portal Enhancements — **DONE**
**What**: Expand the applicant portal (`/my-submission`) with:
- Application status tracking with timeline (submitted -> under review -> semifinalist -> finalist)
- Ability to upload supplementary documents after initial submission (within a deadline)
- Team member management (add/remove members, update roles)
- Communication channel with mentors (if assigned)
**Why**: The current applicant portal shows submission details and team management, but lacks a status timeline and post-submission document upload. These are standard features in grant/competition platforms. The team management page exists but could be enriched.
**Who benefits**: Applicants
**Scope**: Enhance the existing `/my-submission/[id]` pages with status timeline component, add supplementary upload endpoint, enrich team management. ~5-7 days.
---
#### 5.2.6 Round Templates
**What**: Allow admins to create round templates that pre-configure evaluation criteria, assignment constraints, voting windows, and filtering rules. When creating a new round, they can select a template instead of configuring everything from scratch.
**Why**: If the MOPC runs annually, admins will repeat similar configurations each year. Templates reduce setup time and ensure consistency across editions. Currently, every new round requires manual configuration of all parameters.
**Who benefits**: Program Admins, Super Admins
**Scope**: Add a `RoundTemplate` model, create template management UI, add "Create from template" option in round creation. ~3-5 days.
---
#### 5.2.7 Jury Member Availability & Workload Preferences
**What**: Allow jury members to set their availability (date ranges when they can evaluate) and preferred workload (number of projects they are comfortable reviewing). Surface this to admins during assignment.
**Why**: The current system has `maxAssignments` per user but no availability dates. Jurors may have travel or work conflicts during part of the voting window. Knowing availability helps admins assign projects more effectively.
**Who benefits**: Jury Members, Program Admins
**Scope**: Add availability fields to user profile, surface in assignment UI, factor into smart assignment algorithm. ~3-4 days.
---
#### 5.2.8 Real-Time Voting Dashboard for Live Events
**What**: Enhance the live voting feature with a real-time scoreboard using Server-Sent Events or WebSocket polling. Currently, the live voting uses standard HTTP polling. Add a large-screen "presentation mode" showing live score animations as votes come in.
**Why**: The live voting feature (`/admin/rounds/[id]/live-voting`) and public scores page (`/live-scores/[sessionId]`) exist but use standard queries with manual refresh. For a live event with an audience, real-time updates with animations create a much more engaging experience.
**Who benefits**: Event organizers, audience, Jury Members at live events
**Scope**: Add SSE or WebSocket endpoint for vote updates, create presentation mode component with animations, add audience count display. ~5-7 days.
---
#### 5.2.9 AI-Powered Evaluation Summary — **DONE**
**What**: After all evaluations are submitted for a project, use AI to generate a summary of the jurors' collective feedback. Group common themes, highlight strengths and weaknesses mentioned by multiple jurors.
**Why**: Admins currently see individual evaluation scores and feedback text. Synthesizing feedback from 3-5 jurors manually is time-consuming. An AI summary helps admins quickly understand the consensus view, and could be shared with applicants as constructive feedback.
**Who benefits**: Program Admins, Applicants (if feedback is shared)
**Scope**: Add AI summary service using existing OpenAI integration, create summary generation endpoint (on-demand, not automatic), add summary display in project detail view. ~3-5 days.
---
### Long-Term (Ambitious, High Impact)
#### 5.3.1 Public Website Module
**What**: Build the public-facing website for monaco-opc.com, including:
- Homepage with program overview, timeline, and partner logos
- "Apply" landing page with program details and application form link
- Past winners gallery with project descriptions and photos/videos
- News/blog section for announcements
- Partner showcase page
**Why**: Listed as a "Future" feature in CLAUDE.md. The platform currently has no public website -- only the internal portal. A public site is essential for program visibility, applicant recruitment, and sponsor recognition.
**Who benefits**: Applicants (discovery), Partners (visibility), Program (credibility)
**Scope**: Build Next.js pages under `(public)` route group, CMS-like content management for news/blog, integrate existing partner data. ~15-25 days.
---
#### 5.3.2 Communication Hub
**What**: Add an integrated messaging system within the platform:
- Admin-to-jury broadcast messages
- Admin-to-team (applicant) messages with templates
- Mentor-to-team direct messaging
- Message templates for common communications (congratulations, not selected, next steps)
- Integration with existing email notification system
**Why**: Currently, communications happen outside the platform (email, potentially WhatsApp). The WhatsApp integration library exists (`src/lib/whatsapp/`) but is not wired to any router. An in-platform communication hub creates a single source of truth for all program communications and enables better audit trails.
**Who benefits**: All roles
**Scope**: Add Message model, create messaging router, build inbox/compose UI, integrate with email and potentially WhatsApp providers. ~10-15 days.
---
#### 5.3.3 Advanced Analytics & Reporting Dashboard
**What**: Expand analytics with:
- Cross-round comparison (how did scores change between Round 1 and Round 2 for advancing projects?)
- Juror consistency analysis (standard deviation per juror, score correlation between jurors)
- Diversity metrics (geographic distribution of advancing projects, category balance)
- Year-over-year trends (if multi-edition data exists)
- Exportable PDF reports with charts for board presentations
- Custom report builder (select metrics, date ranges, filters)
**Why**: The current analytics router provides good per-round views (score distribution, timeline, workload, rankings, criteria scores, geographic distribution), but lacks cross-round and historical analysis. For a program that runs annually, trend analysis is valuable for demonstrating impact to stakeholders.
**Who benefits**: Program Admins, Super Admins, Observers, Board Members
**Scope**: Add cross-round query endpoints, create new chart components, build PDF export using a library like @react-pdf/renderer. ~10-15 days.
---
#### 5.3.4 Applicant Self-Service with Draft Saving
**What**: Enhance the public application form to support:
- Save as draft and resume later (using magic link or email verification)
- Multi-step form with progress saving between sessions
- File uploads during application (pitch deck, business plan, video)
- Application preview before final submission
**Why**: The current application form (`/apply/[slug]`) submits everything in one go. For complex applications requiring a business plan and team details, applicants may need multiple sessions to complete their submission. Draft saving is standard in grant application platforms.
**Who benefits**: Applicants
**Scope**: Add draft application model, magic link authentication for applicants, file upload integration in application form, preview component. ~7-10 days.
---
#### 5.3.5 Webhooks & API Integration Layer
**What**: Add a webhook system that fires events when key actions occur:
- Application submitted
- Project status changed (advanced, rejected)
- Evaluation submitted
- Round status changed
- Award winner selected
Allow admins to configure webhook URLs for external integrations (CRM, Slack, custom dashboards).
**Why**: The platform is currently self-contained. Organizations often need to integrate with external systems (CRM for applicant tracking, Slack for admin notifications, donor databases). A webhook system enables this without custom code for each integration.
**Who benefits**: Program Admins, Super Admins, external system integrations
**Scope**: Add Webhook model (url, events, secret), create webhook dispatch service, add admin webhook configuration UI. ~5-7 days.
---
#### 5.3.6 Peer Review / Collaborative Evaluation Notes
**What**: After individual evaluations are submitted (and locked), allow jurors assigned to the same project to see anonymized summaries of other jurors' scores and feedback. Enable optional discussion threads for projects where scores diverge significantly.
**Why**: In academic peer review, after independent evaluation, reviewers often discuss divergent opinions to reach consensus. Currently, jurors submit in isolation with no visibility into other evaluations. For borderline projects, discussion can improve decision quality.
**Who benefits**: Jury Members, Program Admins (better decisions)
**Scope**: Add post-evaluation discussion model, create discussion thread UI, implement anonymization for jury comments, add divergence detection logic. ~7-10 days.
---
### Improvements to Existing Features
#### 5.4.1 Smart Assignment Algorithm Improvements — **DONE**
**Current state**: The smart assignment algorithm in `assignment.ts:getSuggestions` uses expertise matching (35%), load balancing (20%), and under-min-target bonus (15%). The AI assignment uses GPT for more nuanced matching.
**Improvement**: Add the following factors to the algorithm:
- **Geographic diversity**: Ensure projects from the same country are not all assigned to the same juror
- **Previous round familiarity**: If a juror evaluated a project in Round 1, prioritize assigning them the same project in Round 2 for continuity (or explicitly avoid it for fresh perspective, configurable)
- **Conflict avoidance**: If COI declarations are implemented, auto-exclude conflicted jurors
**Scope**: Modify the scoring algorithm in the getSuggestions and AI assignment services. ~2-3 days.
---
#### 5.4.2 Evaluation Form Flexibility — **DONE**
**Current state**: Evaluation criteria are configurable per round with labels, descriptions, scales, and weights. Criteria support numeric scoring (1-10).
**Improvement**:
- Add support for **text-only criteria** (qualitative feedback without a numeric score)
- Add support for **yes/no criteria** (boolean questions like "Does this project have a viable business model?")
- Add **conditional criteria** (show criterion B only if criterion A score is above 7)
- Add **criterion grouping** (organize criteria into sections like "Innovation", "Impact", "Feasibility")
**Scope**: Extend the criteria JSON schema, update the evaluation form component to render different input types. ~3-5 days.
---
#### 5.4.3 Audit Log Enhancements
**Current state**: Comprehensive audit logging exists for most actions. The audit page shows logs with filtering by user, action type, entity type, and date range.
**Improvement**:
- Add **before/after diff** for update actions (store previous values alongside new values)
- Add **session tracking** (group actions from the same login session)
- Add **anomaly detection** (flag unusual patterns like bulk deletes or rapid score changes)
- Add **audit log retention policy** (auto-archive logs older than X months)
**Scope**: Enhance audit log creation to capture previous state, add session ID tracking, build anomaly detection service. ~5-7 days.
---
#### 5.4.4 File Management Improvements
**Current state**: Files are uploaded to MinIO with pre-signed URLs. Files are associated with projects and have types (exec summary, presentation, video, etc.).
**Improvement**:
- Add **file versioning** (track file replacements, keep history)
- Add **file preview** (inline PDF viewer, video player, image thumbnails) instead of download-only
- Add **file size and type validation** on upload (client-side + server-side)
- Add **virus scanning** integration for uploaded files
- Add **bulk file download** (zip multiple project files for offline review)
**Scope**: Add file version tracking, integrate a PDF viewer component, add validation middleware. ~5-7 days.
---
#### 5.4.5 Live Voting UX Improvements
**Current state**: Live voting allows admin to control sessions, set project order, start/stop voting windows. Jury members vote on the current project.
**Improvement**:
- Add **QR code generation** for the voting URL so audience members can scan and vote on their phones
- Add **countdown timer** with visual animations when time is running low
- Add **score reveal animation** after voting closes for each project
- Add **tie-breaking mechanism** configuration
- Add **audience vote** option (separate from jury vote, displayed alongside but not counted in official scoring)
**Scope**: QR code generation, animation components, tie-breaking logic. ~5-7 days.
---
#### 5.4.6 Mentor Dashboard Enhancements
**Current state**: Mentors can view their assigned projects, project details, team members, and files. The mentor dashboard shows a list of assignments.
**Improvement**:
- Add **mentoring milestones/checklist** (configurable per program: "Initial meeting", "Business plan review", "Pitch preparation")
- Add **mentor notes** on each project (private notes visible only to the mentor and admins)
- Add **mentor-to-team communication** within the platform
- Add **mentor activity tracking** (when did the mentor last view the project?)
- Add **mentorship completion status** tracking
**Scope**: Add MentorNote model, milestone checklist model, enhance mentor project detail page. ~5-7 days.
---
#### 5.4.7 Application Form Builder (Admin Configurable)
**Current state**: The application form has a fixed schema defined in `application.ts` with hard-coded fields (category, contact info, project details, team members, additional info).
**Improvement**: Allow admins to configure the application form fields per round, similar to how evaluation criteria are configurable. Support:
- Custom text fields, dropdowns, checkboxes
- Required vs optional fields
- Conditional fields (show field B if field A has value X)
- Custom validation rules
- Field ordering via drag-and-drop
**Why**: Different programs or editions may require different application information. The current hard-coded form works for MOPC but limits reusability.
**Scope**: Create form builder model (similar to EvaluationForm's criteriaJson), build form builder UI, dynamic form renderer. ~7-10 days.
---
### Priority Matrix
| Priority | Feature | Impact | Effort | Status |
|----------|---------|--------|--------|--------|
| P0 | 5.1.1 Jury Evaluation Reminders | High | Low | **DONE** |
| P0 | 5.1.2 Evaluation Progress Indicator | High | Very Low | **DONE** |
| P0 | 5.1.3 Bulk Project Status Update | High | Low | **DONE** |
| P0 | 5.1.6 Conflict of Interest Declaration | High | Low | **DONE** |
| P1 | 5.1.4 Export Filtering Results | Medium | Very Low | **DONE** |
| P1 | 5.1.5 Observer Access to Reports | Medium | Low | **DONE** |
| P1 | 5.2.1 Email Digest Notifications | High | Medium | |
| P1 | 5.2.5 Applicant Portal Enhancements | High | Medium | **DONE** |
| P1 | 5.2.9 AI Evaluation Summary | High | Medium | **DONE** |
| P1 | 5.4.2 Evaluation Form Flexibility | High | Medium | **DONE** |
| P2 | 5.2.2 Evaluation Calibration Tool | Medium | Medium | |
| P2 | 5.2.4 Project Comparison View | Medium | Medium | |
| P2 | 5.2.6 Round Templates | Medium | Medium | |
| P2 | 5.2.7 Jury Availability Preferences | Medium | Medium | |
| P2 | 5.2.8 Real-Time Live Voting | Medium | Medium | |
| P2 | 5.4.1 Smart Assignment Improvements | Medium | Low | **DONE** |
| P2 | 5.4.4 File Management Improvements | Medium | Medium | |
| P2 | 5.4.5 Live Voting UX Improvements | Medium | Medium | |
| P2 | 5.4.6 Mentor Dashboard Enhancements | Medium | Medium | |
| P3 | 5.2.3 Multi-Language Support | High | High | |
| P3 | 5.3.1 Public Website Module | High | High | |
| P3 | 5.3.2 Communication Hub | High | High | |
| P3 | 5.3.3 Advanced Analytics Dashboard | Medium | High | |
| P3 | 5.3.4 Applicant Self-Service Drafts | Medium | Medium | |
| P3 | 5.3.5 Webhooks & API Integration | Medium | Medium | |
| P3 | 5.3.6 Peer Review / Collaborative Notes | Medium | High | |
| P3 | 5.4.3 Audit Log Enhancements | Low | Medium | |
| P3 | 5.4.7 Application Form Builder | Medium | High | |