Platform review round 2: audit logging migration, nav unification, DB indexes, and UI polish

- Migrate ~41 inline audit log calls to shared logAudit() utility across all routers
- Add transaction-aware prisma parameter to logAudit() for atomic operations
- Unify jury/mentor/observer navigation into shared RoleNav component
- Add composite DB indexes (Evaluation, GracePeriod, AuditLog) for query performance
- Fix profile page: consolidate dual save buttons, proper useEffect initialization
- Enhance auth error page with MOPC branding and navigation
- Improve observer dashboard with prominent read-only badge
- Fix DI-3: fetch projects before bulk status update for accurate notifications
- Remove unused aiBoost field from smart-assignment scoring
- Add shared image-upload utility and structured logger module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-05 21:09:06 +01:00
parent 8d0979e649
commit 002a9dbfc3
34 changed files with 1688 additions and 1782 deletions

View File

@ -1,5 +1,11 @@
# 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
@ -16,7 +22,7 @@ The MOPC platform demonstrates strong foundational UI/UX work. shadcn/ui provide
### 1.1 Design System & Theming
#### DS-1: Public layout uses Inter font instead of Montserrat (Medium)
#### DS-1: Public layout uses Inter font instead of Montserrat (Medium) — **DONE**
**File**: `src/app/(public)/layout.tsx:4-5`
@ -33,7 +39,7 @@ const inter = Inter({ subsets: ['latin'] })
---
#### DS-2: Public layout uses placeholder logo instead of MOPC logo component (Low)
#### DS-2: Public layout uses placeholder logo instead of MOPC logo component (Low) — **DONE**
**File**: `src/app/(public)/layout.tsx:20-22`
@ -49,7 +55,7 @@ The public layout renders a hardcoded `<div>` with "M" text instead of using the
---
#### DS-3: Dark mode CSS defined but no toggle exposed (Low)
#### DS-3: Dark mode CSS defined but no toggle exposed (Low) — **DONE**
**File**: `src/app/globals.css` (dark mode variables block)
@ -59,7 +65,7 @@ Full dark mode color tokens are defined in globals.css under `@media (prefers-co
---
#### DS-4: Card `CardTitle` default size is too large and always overridden (Low)
#### DS-4: Card `CardTitle` default size is too large and always overridden (Low) — **DONE**
**File**: `src/components/ui/card.tsx`
@ -71,7 +77,7 @@ Full dark mode color tokens are defined in globals.css under `@media (prefers-co
### 1.2 Navigation & Information Architecture
#### NAV-1: Inconsistent active state styling across role navigation components (Medium)
#### 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`
@ -84,7 +90,7 @@ The observer nav uses a completely different active state style (solid filled ba
---
#### NAV-2: Notification bell links are hardcoded to admin paths (Medium)
#### NAV-2: Notification bell links are hardcoded to admin paths (Medium) — **DONE**
**File**: `src/components/shared/notification-bell.tsx`
@ -94,7 +100,7 @@ The notification bell component links to `/admin/settings` and `/admin/notificat
---
#### NAV-3: Jury nav is missing Awards link (Low)
#### NAV-3: Jury nav is missing Awards link (Low) — **DONE**
**File**: `src/components/layouts/jury-nav.tsx`
@ -104,7 +110,7 @@ The jury navigation includes Dashboard, My Assignments, and Learning Hub. There
---
#### NAV-4: Admin sidebar user section uses hardcoded colors (Low)
#### NAV-4: Admin sidebar user section uses hardcoded colors (Low) — **DONE**
**File**: `src/components/layouts/admin-sidebar.tsx:247-267`
@ -116,7 +122,7 @@ The user info section at the bottom of the admin sidebar uses hardcoded `slate-2
### 1.3 Mobile Responsiveness
#### MOB-1: Admin rounds table is not responsive (High)
#### MOB-1: Admin rounds table is not responsive (High) — **DONE**
**File**: `src/app/(admin)/admin/rounds/page.tsx`
@ -126,7 +132,7 @@ The rounds management page uses a fixed 7-column grid (`grid-cols-7`) that does
---
#### MOB-2: Pagination component is not mobile-optimized (Medium)
#### MOB-2: Pagination component is not mobile-optimized (Medium) — **DONE**
**File**: `src/components/shared/pagination.tsx`
@ -136,7 +142,7 @@ The pagination component renders all page numbers without truncation. For datase
---
#### MOB-3: Admin dashboard stat cards lack stacking on small screens (Low)
#### MOB-3: Admin dashboard stat cards lack stacking on small screens (Low) — **DONE**
**File**: `src/app/(admin)/admin/page.tsx`
@ -148,7 +154,7 @@ The admin dashboard uses `grid-cols-2 md:grid-cols-4` for the stats grid. On ver
### 1.4 UX States & Feedback
#### UX-1: No global 404 or error pages (Medium)
#### UX-1: No global 404 or error pages (Medium) — **DONE**
**Files**: Only `src/app/(auth)/error/page.tsx` exists (auth-specific errors)
@ -158,7 +164,7 @@ There is no `src/app/not-found.tsx` or `src/app/error.tsx`. Users who navigate t
---
#### UX-2: Profile settings page has three identical save buttons (Medium)
#### 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`
@ -168,7 +174,7 @@ The profile page has three separate "Save" buttons (Save Changes, Save Preferenc
---
#### UX-3: Form state initialized via conditional in render body (Low)
#### UX-3: Form state initialized via conditional in render body (Low) — **DONE**
**File**: `src/app/(settings)/settings/profile/page.tsx:76-84`
@ -186,7 +192,7 @@ This pattern of calling `setState` during render is fragile. React may batch the
---
#### UX-4: Mentor dashboard uses client-side loading instead of Suspense (Low)
#### UX-4: Mentor dashboard uses client-side loading instead of Suspense (Low) — **DONE**
**File**: `src/app/(mentor)/mentor/page.tsx`
@ -198,7 +204,7 @@ The mentor dashboard conditionally renders a loading spinner based on `isLoading
### 1.5 Interaction Patterns
#### INT-1: Evaluation form is excellent (Positive)
#### INT-1: Evaluation form is excellent (Positive) — **N/A**
**File**: `src/components/forms/evaluation-form.tsx`
@ -215,7 +221,7 @@ This should be the UX benchmark for all forms in the platform.
---
#### INT-2: Button press feedback is well-implemented (Positive)
#### INT-2: Button press feedback is well-implemented (Positive) — **N/A**
**File**: `src/components/ui/button.tsx`
@ -223,7 +229,7 @@ All buttons include `active:scale-[0.98]` for tactile press feedback. Combined w
---
#### INT-3: File upload component is well-designed (Positive)
#### INT-3: File upload component is well-designed (Positive) — **N/A**
**File**: `src/components/shared/file-upload.tsx`
@ -231,7 +237,7 @@ The file upload supports drag-and-drop with visual hover state, progress bar dur
---
#### INT-4: Onboarding wizard has strong UX (Positive)
#### INT-4: Onboarding wizard has strong UX (Positive) — **N/A**
**File**: `src/app/(auth)/onboarding/page.tsx`
@ -246,7 +252,7 @@ The multi-step onboarding uses:
### 1.6 Accessibility
#### A11Y-1: Muted foreground color may have insufficient contrast (Medium)
#### A11Y-1: Muted foreground color may have insufficient contrast (Medium) — **DONE**
**File**: `src/app/globals.css`
@ -258,7 +264,7 @@ Many description texts and helper texts throughout the platform use `text-muted-
---
#### A11Y-2: Focus styles are properly implemented (Positive)
#### A11Y-2: Focus styles are properly implemented (Positive) — **N/A**
**File**: `src/app/globals.css`, `src/components/ui/button.tsx`
@ -266,7 +272,7 @@ The design system includes `focus-visible:ring-2 focus-visible:ring-ring` on int
---
#### A11Y-3: Skeleton loading states provide good screen reader context (Positive)
#### A11Y-3: Skeleton loading states provide good screen reader context (Positive) — **N/A**
**Files**: Multiple pages using `<Skeleton>` components
@ -276,7 +282,7 @@ Loading states use semantic skeleton placeholders instead of spinners, which giv
### 1.7 Visual Polish
#### VP-1: Landing page is minimal and unbranded (Medium)
#### VP-1: Landing page is minimal and unbranded (Medium) — **DONE**
**File**: `src/app/page.tsx`
@ -286,7 +292,7 @@ The landing/home page is very minimal. For a platform representing the Monaco Oc
---
#### VP-2: Auth error page is generic (Low)
#### VP-2: Auth error page is generic (Low) — **DONE**
**File**: `src/app/(auth)/error/page.tsx`
@ -296,7 +302,7 @@ The auth error page shows a basic card with the error message. It does not use M
---
#### VP-3: Observer dashboard "read-only" notice could be more prominent (Low)
#### VP-3: Observer dashboard "read-only" notice could be more prominent (Low) — **DONE**
**File**: `src/app/(observer)/observer/page.tsx`
@ -361,7 +367,7 @@ The schema is well-structured for its domain with good use of Prisma features. T
### Schema Design
#### SD-1: Project.roundId is required but treated as optional (High)
#### 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`
@ -373,7 +379,7 @@ The `Project` model declares `roundId String` as a required (non-nullable) field
---
#### SD-2: Evaluation versioning is declared but not implemented (Medium)
#### 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`
@ -385,7 +391,7 @@ The `Evaluation` model has a `version Int @default(1)` field and the CLAUDE.md s
---
#### SD-3: Soft deletes not implemented (Medium)
#### SD-3: Soft deletes not implemented (Medium)**DEFERRED** (massive schema change, requires careful planning)
**Files**: All routers using `.delete()`, `prisma/schema.prisma`
@ -397,7 +403,7 @@ The CLAUDE.md architecture decisions (ADR #7) state "Soft deletes: Audit trail,
---
#### SD-4: LiveVotingSession.status is a String, not an Enum (Low)
#### SD-4: LiveVotingSession.status is a String, not an Enum (Low) — **DONE**
**Files**: `prisma/schema.prisma:918`
@ -409,7 +415,7 @@ The `LiveVotingSession.status` field is typed as `String @default("NOT_STARTED")
---
#### SD-5: InAppNotification.priority and type are Strings, not Enums (Low)
#### SD-5: InAppNotification.priority and type are Strings, not Enums (Low) — **DONE**
**Files**: `prisma/schema.prisma:737-738`
@ -419,7 +425,7 @@ Similar to SD-4, `priority` and `type` on `InAppNotification` are strings but ha
---
#### SD-6: Redundant date fields on Round (Low)
#### SD-6: Redundant date fields on Round (Low)**DEFERRED** (fields still actively used in application/round routers)
**Files**: `prisma/schema.prisma:348-353`
@ -431,7 +437,7 @@ The `Round` model has both `submissionDeadline` and `submissionEndDate` with a c
### Indexing
#### IX-1: Missing composite index on Evaluation for round-based queries (High)
#### IX-1: Missing composite index on Evaluation for round-based queries (High) — **DONE**
**Files**: `prisma/schema.prisma:554-584`
@ -443,7 +449,7 @@ Many queries filter evaluations by `assignment.roundId` (e.g., `evaluation.listB
---
#### IX-2: AuditLog table will grow unbounded (Medium)
#### IX-2: AuditLog table will grow unbounded (Medium)**DEFERRED** (requires DBA decision on partitioning strategy)
**Files**: `prisma/schema.prisma:638-663`
@ -455,7 +461,7 @@ The `AuditLog` table has good indexes (`userId`, `action`, `entityType+entityId`
---
#### IX-3: User.email has both @unique and @@index (Low)
#### IX-3: User.email has both @unique and @@index (Low) — **DONE**
**Files**: `prisma/schema.prisma:192, 262`
@ -467,7 +473,7 @@ The `AuditLog` table has good indexes (`userId`, `action`, `entityType+entityId`
---
#### IX-4: Missing index on GracePeriod for combined lookups (Low)
#### IX-4: Missing index on GracePeriod for combined lookups (Low) — **DONE**
**Files**: `prisma/schema.prisma:590-612`
@ -479,7 +485,7 @@ The `evaluation.submit` mutation queries grace periods with `roundId + userId +
### Query Optimization
#### QO-1: Analytics getProjectRankings loads all project data including all assignments and evaluations (High)
#### QO-1: Analytics getProjectRankings loads all project data including all assignments and evaluations (High) — **DONE**
**Files**: `src/server/routers/analytics.ts:148-207`
@ -491,7 +497,7 @@ The `getProjectRankings` query does `include: { assignments: { include: { evalua
---
#### QO-2: getTags fetches all projects to extract unique tags (Medium)
#### QO-2: getTags fetches all projects to extract unique tags (Medium) — **DONE**
**Files**: `src/server/routers/project.ts:580-599`
@ -503,7 +509,7 @@ The `getTags` query fetches ALL projects with `select: { tags: true }` then dedu
---
#### QO-3: Project.list includes full files array for every project (Medium)
#### QO-3: Project.list includes full files array for every project (Medium) — **DONE**
**Files**: `src/server/routers/project.ts:133-134`
@ -515,7 +521,7 @@ The project list query includes `files: true`, fetching all file records for eve
---
#### QO-4: Sequential notification sends in bulk operations (Medium)
#### 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`
@ -527,7 +533,7 @@ In `assignment.bulkCreate` and `user.bulkCreate`, notifications are sent sequent
---
#### QO-5: Export queries fetch unbounded data (Medium)
#### QO-5: Export queries fetch unbounded data (Medium) — **DONE**
**Files**: `src/server/routers/export.ts:16-34, 106-118`
@ -539,7 +545,7 @@ Export queries for evaluations and project scores have no pagination and no limi
---
#### QO-6: N+1 in mentor.getSuggestions (Medium)
#### QO-6: N+1 in mentor.getSuggestions (Medium) — **DONE**
**Files**: `src/server/routers/mentor.ts:50-78`
@ -551,7 +557,7 @@ After getting AI suggestions, each mentor is fetched individually in a `Promise.
---
#### QO-7: Audit log creation not batched or fire-and-forget (Low)
#### QO-7: Audit log creation not batched or fire-and-forget (Low)**DONE** (migrated to logAudit utility with try-catch)
**Files**: All routers
@ -563,7 +569,7 @@ Every mutation `await`s the audit log creation, meaning the user's response is d
### Data Integrity
#### DI-1: Evaluation submission and assignment update are not in a transaction (High)
#### DI-1: Evaluation submission and assignment update are not in a transaction (High) — **DONE**
**Files**: `src/server/routers/evaluation.ts:200-213`
@ -581,7 +587,7 @@ await ctx.prisma.$transaction([
---
#### DI-2: Project create + audit log not in a transaction (Medium)
#### 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
@ -593,7 +599,7 @@ Across all routers, the pattern is: create/update entity, then create audit log
---
#### DI-3: bulkUpdateStatus does not check project ownership before notification (Medium)
#### DI-3: bulkUpdateStatus does not check project ownership before notification (Medium) — **DONE**
**Files**: `src/server/routers/project.ts:620-742`
@ -605,7 +611,7 @@ The `bulkUpdateStatus` mutation uses `updateMany` (which doesn't return updated
---
#### DI-4: Assignment uniqueness constraint may be bypassed by skipDuplicates (Low)
#### DI-4: Assignment uniqueness constraint may be bypassed by skipDuplicates (Low) — **DONE**
**Files**: `src/server/routers/assignment.ts:427-434`
@ -617,7 +623,7 @@ The `bulkCreate` mutation uses `createMany({ skipDuplicates: true })`. While thi
---
#### DI-5: No foreign key constraint on SpecialAward.winnerOverriddenBy (Low)
#### DI-5: No foreign key constraint on SpecialAward.winnerOverriddenBy (Low) — **DONE**
**Files**: `prisma/schema.prisma:1213`
@ -629,7 +635,7 @@ The `winnerProjectId` field has a relation to `Project` with `onDelete: SetNull`
### Performance
#### PF-1: Large transaction in filtering.executeRules (High)
#### PF-1: Large transaction in filtering.executeRules (High) — **DONE**
**Files**: `src/server/routers/filtering.ts:503-531`
@ -641,7 +647,7 @@ The `executeRules` mutation creates a transaction with one `upsert` per project.
---
#### PF-2: Description field text search uses ILIKE without text index (Medium)
#### 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`
@ -653,7 +659,7 @@ Project search uses `{ description: { contains: search, mode: 'insensitive' } }`
---
#### PF-3: Assignment stats query makes 5 separate database calls (Low)
#### PF-3: Assignment stats query makes 5 separate database calls (Low) — **DONE**
**Files**: `src/server/routers/assignment.ts:530-554`
@ -701,7 +707,7 @@ The `getStats` query runs 4 parallel queries via `Promise.all` plus a separate `
### 4.1 Duplicate Code
#### 4.1.1 `toProjectWithRelations` duplicated across 3 AI services
#### 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`
@ -714,7 +720,7 @@ All three files contain nearly identical `toProjectWithRelations()` functions th
---
#### 4.1.2 AI service batch processing boilerplate
#### 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`
@ -738,7 +744,7 @@ Each batch processor also repeats: build params -> call API -> extract usage ->
---
#### 4.1.3 Avatar and Logo routers are structural clones
#### 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)
@ -758,7 +764,7 @@ The only differences are: entity type (User vs Project), field names (profileIma
---
#### 4.1.4 Navigation components (jury, mentor, observer) are near-duplicates
#### 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`
@ -771,7 +777,7 @@ All three components share the same structure: mobile hamburger menu, desktop na
---
#### 4.1.5 Expertise matching logic duplicated between AI assignment fallback and smart assignment
#### 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`)
@ -783,7 +789,7 @@ Both implement tag-based expertise matching with slight variations. The fallback
---
#### 4.1.6 Audit logging: two competing patterns
#### 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`
@ -796,7 +802,7 @@ Most routers create audit logs inline without try-catch, meaning an audit failur
---
#### 4.1.7 Batch tagging logic duplicated between service and router
#### 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`)
@ -811,7 +817,7 @@ Most routers create audit logs inline without try-catch, meaning an audit failur
### 4.2 Dead Code
#### 4.2.1 `getObjectInfo` function in minio.ts is never called
#### 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.
@ -821,7 +827,7 @@ The `getObjectInfo` function is exported but never imported or used anywhere in
---
#### 4.2.2 `isValidImageSize` function in storage/index.ts is never called
#### 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.
@ -831,7 +837,7 @@ Exported but never imported anywhere. Image size validation is not performed ser
---
#### 4.2.3 `clearStorageProviderCache` is never called
#### 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.
@ -841,7 +847,7 @@ This function is defined to clear the cached storage provider when settings chan
---
#### 4.2.4 `deleteObject` in minio.ts is only used indirectly
#### 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.
@ -851,7 +857,7 @@ Only referenced via the S3 storage provider (`src/lib/storage/s3-provider.ts`).
---
#### 4.2.5 Deprecated batch tag endpoints still exposed
#### 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`.
@ -861,7 +867,7 @@ Two endpoints are marked `@deprecated` (`batchTagProjects`, `batchTagProgramProj
---
#### 4.2.6 `batchTagProjects` and `batchTagProgramProjects` service functions likely unused
#### 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`
@ -873,7 +879,7 @@ These two exported functions are no longer called by the tag router (which uses
---
#### 4.2.7 `twilio` npm package is never imported
#### 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.
@ -885,7 +891,7 @@ The `twilio` package (v5.4.0) is listed as a dependency, but the Twilio WhatsApp
### 4.3 Redundant Code
#### 4.3.1 Two storage abstraction layers: minio.ts and storage/
#### 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)
@ -897,7 +903,7 @@ Some routers use `minio.ts` directly (`file.ts`, `partner.ts`, `learningResource
---
#### 4.3.2 Debug console.log statements in user router
#### 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:
@ -913,7 +919,7 @@ These appear to be leftover development debug statements, not structured logging
---
#### 4.3.3 Excessive console.log in AI services
#### 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
@ -931,7 +937,7 @@ Total: ~51 console.log/warn/error statements across AI services. While useful fo
### 4.4 Unfinished Code
#### 4.4.1 TODO: Send invitation email to new team member
#### 4.4.1 TODO: Send invitation email to new team member — **DONE**
**File:** `src/server/routers/applicant.ts:659`
```typescript
@ -945,7 +951,7 @@ The team member addition flow creates the team member record but never sends an
---
#### 4.4.2 `aiBoost` field always returns 0
#### 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.
@ -955,13 +961,13 @@ The `ScoreBreakdown` type includes an `aiBoost` field (described as "Reserved: 0
---
### 4.5 Unused Dependencies
### 4.5 Unused Dependencies — **ALL DONE**
| Package | Evidence | Recommendation |
|---------|----------|----------------|
| `twilio` (v5.4.0) | Never imported; Twilio provider uses raw `fetch()` | Remove from package.json |
| `autoprefixer` (v10.4.20) | Only referenced in postcss config; Tailwind v4 includes autoprefixer | Verify and potentially remove |
| `@types/leaflet` (v1.9.21) | Listed in `dependencies` not `devDependencies` | Move to devDependencies |
| 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** |
---
@ -995,7 +1001,7 @@ The `ScoreBreakdown` type includes an `aiBoost` field (described as "Reserved: 0
### Critical Findings
#### CRIT-1: Path Traversal Bypass in Local Storage Provider
#### CRIT-1: Path Traversal Bypass in Local Storage Provider — **DONE**
**Affected file**: `src/lib/storage/local-provider.ts:65-68`
@ -1020,7 +1026,7 @@ if (!resolved.startsWith(path.resolve(this.basePath))) {
---
#### CRIT-2: Timing-Unsafe Signature Comparison
#### CRIT-2: Timing-Unsafe Signature Comparison — **DONE**
**Affected file**: `src/lib/storage/local-provider.ts:56-63`
@ -1044,7 +1050,7 @@ return sigBuffer.length === expectedBuffer.length
---
#### CRIT-3: Email/Password Change API Routes Lack Proper Authentication
#### 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`
@ -1060,7 +1066,7 @@ The change-password route at `src/app/api/email/change-password/route.ts:97-109`
### High Severity Findings
#### HIGH-1: In-Memory Rate Limiting Does Not Scale
#### 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`
@ -1076,7 +1082,7 @@ const failedAttempts = new Map<string, { count: number; lockedUntil: number }>()
---
#### HIGH-2: Health Check Endpoint Leaks Database Error Details
#### HIGH-2: Health Check Endpoint Leaks Database Error Details — **DONE**
**Affected file**: `src/app/api/health/route.ts:29`
@ -1091,7 +1097,7 @@ error: error instanceof Error ? error.message : 'Unknown error',
---
#### HIGH-3: Local Storage Fallback Secret Key
#### HIGH-3: Local Storage Fallback Secret Key — **DONE**
**Affected file**: `src/lib/storage/local-provider.ts:6`
@ -1107,7 +1113,7 @@ If `NEXTAUTH_SECRET` is not set, the signing key falls back to a hardcoded value
---
#### HIGH-4: MinIO Client Defaults to Insecure Credentials
#### HIGH-4: MinIO Client Defaults to Insecure Credentials — **DONE**
**Affected file**: `src/lib/minio.ts:23-24`
@ -1122,7 +1128,7 @@ secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
---
#### HIGH-5: Application Submission Creates Users Without Verification
#### HIGH-5: Application Submission Creates Users Without Verification**DONE** (added rate limiting)
**Affected file**: `src/server/routers/application.ts:148-341`
@ -1141,7 +1147,7 @@ While the APPLICANT role has limited permissions, there is no CAPTCHA, no email
### Medium Severity Findings
#### MED-1: Password Policy Does Not Require Special Characters
#### MED-1: Password Policy Does Not Require Special Characters — **DONE**
**Affected file**: `src/lib/password.ts:34-56`
@ -1153,7 +1159,7 @@ The password validation requires only 8 characters, one uppercase, one lowercase
---
#### MED-2: Audit Logging Failures Are Silently Swallowed
#### 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
@ -1168,7 +1174,7 @@ await prisma.auditLog.create({ ... }).catch(() => {})
---
#### MED-3: File Upload Has No Content-Type or Extension Validation
#### MED-3: File Upload Has No Content-Type or Extension Validation — **DONE**
**Affected file**: `src/server/routers/file.ts:75-125`
@ -1182,7 +1188,7 @@ The local storage upload route at `src/app/api/storage/local/route.ts:111-124` a
---
#### MED-4: File Deletion Does Not Remove Storage Objects
#### MED-4: File Deletion Does Not Remove Storage Objects — **DONE**
**Affected file**: `src/server/routers/file.ts:143-171`
@ -1198,7 +1204,7 @@ When a file record is deleted, only the database entry is removed. The actual fi
---
#### MED-5: IP Address Extraction Relies on Spoofable Header
#### 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`
@ -1213,7 +1219,7 @@ const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown
---
#### MED-6: Public Email Availability Check Enables Enumeration
#### MED-6: Public Email Availability Check Enables Enumeration — **DONE**
**Affected file**: `src/server/routers/application.ts:346-367`
@ -1228,7 +1234,7 @@ return { available: !existing, message: existing ? 'An application with this ema
---
#### MED-7: Assignment Job Status Accessible to All Authenticated Users
#### MED-7: Assignment Job Status Accessible to All Authenticated Users — **DONE**
**Affected file**: `src/server/routers/assignment.ts:1041-1061`
@ -1244,7 +1250,7 @@ getAIAssignmentJobStatus: protectedProcedure
---
#### MED-8: PII Logging in User Router
#### MED-8: PII Logging in User Router — **DONE**
**Affected file**: `src/server/routers/user.ts:246,256`
@ -1262,7 +1268,7 @@ console.log('[user.get] Found user:', user.email)
### Low Severity Findings
#### LOW-1: Session Configuration Could Be Hardened
#### LOW-1: Session Configuration Could Be Hardened — **DONE**
**Affected file**: `src/lib/auth.config.ts:88-90`
@ -1281,7 +1287,7 @@ session: {
---
#### LOW-2: Export Endpoint Allows Large Data Fetches
#### LOW-2: Export Endpoint Allows Large Data Fetches**DONE** (reduced to 5000)
**Affected file**: `src/server/routers/export.ts:266`
@ -1295,7 +1301,7 @@ take: 10000, // Limit export to 10k records
---
#### LOW-3: Project List Allows Up to 5000 Items Per Page
#### LOW-3: Project List Allows Up to 5000 Items Per Page**DONE** (reduced to 200)
**Affected file**: `src/server/routers/project.ts:58`
@ -1309,7 +1315,7 @@ perPage: z.number().int().min(1).max(5000).default(20),
---
#### LOW-4: No CSRF Protection on Custom API Routes
#### 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`
@ -1321,7 +1327,7 @@ These custom API routes (outside of tRPC and NextAuth) accept POST/PUT requests
---
#### LOW-5: Invite Token Validation Has No Specific Rate Limit
#### 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`
@ -1335,43 +1341,43 @@ The `validateInviteToken` is a public procedure protected only by the global tRP
### Informational Findings (Positive Security Practices)
#### INFO-1: Comprehensive Audit Logging
#### 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
#### 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
#### 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
#### 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
#### 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
#### 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
#### 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
#### 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
#### 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
#### 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.
@ -1391,13 +1397,15 @@ Rate limiting is applied to: all tRPC endpoints (100 requests/min), auth POST en
---
## 5. Feature Proposals & Improvements
## 5. Feature Proposals & Improvements**N/A** (new features, not fixes — preserved for future reference)
*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)

View File

@ -93,7 +93,6 @@
"@types/papaparse": "^5.3.15",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0",
"postcss": "^8.4.49",

View File

@ -250,6 +250,7 @@ model User {
// Award overrides
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
// In-app notifications
notifications InAppNotification[] @relation("UserNotifications")
@ -565,7 +566,9 @@ model Evaluation {
binaryDecision Boolean? // Yes/No for semi-finalist
feedbackText String? @db.Text
// Versioning
// Versioning (currently unused - evaluations are updated in-place.
// TODO: Implement proper versioning by creating new rows on re-submission
// if version history is needed for audit purposes)
version Int @default(1)
// Timestamps
@ -580,6 +583,7 @@ model Evaluation {
@@index([status])
@@index([submittedAt])
@@index([formId])
@@index([status, formId])
}
// =============================================================================
@ -608,6 +612,7 @@ model GracePeriod {
@@index([extendedUntil])
@@index([grantedById])
@@index([projectId])
@@index([roundId, userId, extendedUntil])
}
// =============================================================================
@ -659,6 +664,7 @@ model AuditLog {
@@index([action])
@@index([entityType, entityId])
@@index([timestamp])
@@index([entityType, entityId, timestamp])
}
// =============================================================================
@ -1211,7 +1217,7 @@ model SpecialAward {
// Winner
winnerProjectId String?
winnerOverridden Boolean @default(false)
winnerOverriddenBy String?
winnerOverriddenBy String? // FK to User who overrode the winner
sortOrder Int @default(0)
@ -1221,6 +1227,7 @@ model SpecialAward {
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
winnerProject Project? @relation("AwardWinner", fields: [winnerProjectId], references: [id], onDelete: SetNull)
overriddenByUser User? @relation("AwardOverriddenBy", fields: [winnerOverriddenBy], references: [id], onDelete: SetNull)
eligibilities AwardEligibility[]
jurors AwardJuror[]
votes AwardVote[]

View File

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

View File

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

View File

@ -239,19 +239,6 @@ export default function ProfileSettingsPage() {
/>
</div>
<div className="flex justify-end">
<Button
onClick={handleSaveProfile}
disabled={updateProfile.isPending}
>
{updateProfile.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</div>
</CardContent>
</Card>
@ -285,19 +272,6 @@ export default function ProfileSettingsPage() {
</Select>
</div>
<div className="flex justify-end">
<Button
onClick={handleSaveProfile}
disabled={updateProfile.isPending}
>
{updateProfile.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Preferences
</Button>
</div>
</CardContent>
</Card>
@ -320,21 +294,24 @@ export default function ProfileSettingsPage() {
maxTags={15}
/>
</CardContent>
</Card>
{/* Save All Profile Changes */}
<div className="flex justify-end">
<Button
onClick={handleSaveProfile}
disabled={updateProfile.isPending}
size="lg"
>
{updateProfile.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Expertise
Save All Changes
</Button>
</div>
</CardContent>
</Card>
{/* Change Password */}
<Card>

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -2,6 +2,7 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
import { logAudit } from '@/server/utils/audit'
// Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
@ -205,8 +206,8 @@ export const applicantRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Project',
@ -214,7 +215,6 @@ export const applicantRouter = router({
detailsJson: { title: input.title, source: 'applicant_portal' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project

View File

@ -8,6 +8,7 @@ import {
NotificationTypes,
} from '../services/in-app-notification'
import { checkRateLimit } from '@/lib/rate-limit'
import { logAudit } from '@/server/utils/audit'
// Zod schemas for the application form
const teamMemberSchema = z.object({
@ -299,8 +300,8 @@ export const applicationRouter = router({
}
// Create audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: user.id,
action: 'CREATE',
entityType: 'Project',
@ -312,7 +313,6 @@ export const applicationRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Notify applicant of successful submission

View File

@ -15,6 +15,7 @@ import {
notifyAdmins,
NotificationTypes,
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
// Background job execution function
async function runAIAssignmentJob(jobId: string, roundId: string, userId: string) {
@ -355,8 +356,8 @@ export const assignmentRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Assignment',
@ -364,7 +365,6 @@ export const assignmentRouter = router({
detailsJson: input,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Send notification to the assigned jury member
@ -434,15 +434,14 @@ export const assignmentRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'Assignment',
detailsJson: { count: result.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Send notifications to assigned jury members (grouped by user)
@ -499,7 +498,11 @@ export const assignmentRouter = router({
}
}
return { created: result.count }
return {
created: result.count,
requested: input.assignments.length,
skipped: input.assignments.length - result.count,
}
}),
/**
@ -513,8 +516,8 @@ export const assignmentRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Assignment',
@ -525,7 +528,6 @@ export const assignmentRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return assignment
@ -542,6 +544,7 @@ export const assignmentRouter = router({
completedAssignments,
assignmentsByUser,
projectCoverage,
round,
] = await Promise.all([
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({
@ -560,12 +563,11 @@ export const assignmentRouter = router({
_count: { select: { assignments: true } },
},
}),
])
const round = await ctx.prisma.round.findUniqueOrThrow({
ctx.prisma.round.findUniqueOrThrow({
where: { id: input.roundId },
select: { requiredReviews: true },
})
}),
])
const projectsWithFullCoverage = projectCoverage.filter(
(p) => p._count.assignments >= round.requiredReviews
@ -854,8 +856,8 @@ export const assignmentRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: input.usedAI ? 'APPLY_AI_SUGGESTIONS' : 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
@ -866,7 +868,6 @@ export const assignmentRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Send notifications to assigned jury members
@ -953,8 +954,8 @@ export const assignmentRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'APPLY_SUGGESTIONS',
entityType: 'Assignment',
@ -964,7 +965,6 @@ export const assignmentRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Send notifications to assigned jury members

View File

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

View File

@ -1,6 +1,7 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { logAudit } from '@/server/utils/audit'
export const evaluationRouter = router({
/**
@ -213,8 +214,8 @@ export const evaluationRouter = router({
])
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'EVALUATION_SUBMITTED',
entityType: 'Evaluation',
@ -227,7 +228,6 @@ export const evaluationRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return updated

View File

@ -1,5 +1,6 @@
import { z } from 'zod'
import { router, adminProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
export const exportRouter = router({
/**
@ -69,15 +70,14 @@ export const exportRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'Evaluation',
detailsJson: { roundId: input.roundId, count: data.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
@ -154,15 +154,14 @@ export const exportRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'EXPORT',
entityType: 'ProjectScores',
detailsJson: { roundId: input.roundId, count: data.length },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {

View File

@ -2,6 +2,7 @@ import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, protectedProcedure, adminProcedure } from '../trpc'
import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
import { logAudit } from '../utils/audit'
export const fileRouter = router({
/**
@ -55,16 +56,15 @@ export const fileRouter = router({
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min
// Log file access
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'FILE_DOWNLOADED',
entityType: 'ProjectFile',
detailsJson: { bucket: input.bucket, objectKey: input.objectKey },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
}).catch(() => {})
})
return { url }
}),
@ -112,8 +112,8 @@ export const fileRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPLOAD_FILE',
entityType: 'ProjectFile',
@ -125,7 +125,6 @@ export const fileRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {
@ -167,8 +166,8 @@ export const fileRouter = router({
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'DELETE_FILE',
entityType: 'ProjectFile',
@ -180,7 +179,6 @@ export const fileRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return file

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import {
notifyProjectTeam,
NotificationTypes,
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
export const mentorRouter = router({
/**
@ -118,8 +119,9 @@ export const mentorRouter = router({
where: { id: input.mentorId },
})
// Create assignment
const assignment = await ctx.prisma.mentorAssignment.create({
// Create assignment + audit log in transaction
const assignment = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId: input.mentorId,
@ -147,23 +149,24 @@ export const mentorRouter = router({
},
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'MENTOR_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
entityId: created.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
projectTitle: created.project.title,
mentorId: input.mentorId,
mentorName: assignment.mentor.name,
mentorName: created.mentor.name,
method: input.method,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return created
})
// Get team lead info for mentor notification
@ -292,8 +295,8 @@ export const mentorRouter = router({
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_AUTO_ASSIGN',
entityType: 'MentorAssignment',
@ -308,7 +311,6 @@ export const mentorRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Get team lead info for mentor notification
@ -371,13 +373,10 @@ export const mentorRouter = router({
})
}
await ctx.prisma.mentorAssignment.delete({
where: { projectId: input.projectId },
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
// Delete assignment + audit log in transaction
await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'MENTOR_UNASSIGN',
entityType: 'MentorAssignment',
@ -390,7 +389,11 @@ export const mentorRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
await tx.mentorAssignment.delete({
where: { projectId: input.projectId },
})
})
return { success: true }
@ -518,8 +521,8 @@ export const mentorRouter = router({
}
// Create audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'Round',
@ -531,7 +534,6 @@ export const mentorRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return {

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import {
NotificationTypes,
} from '../services/in-app-notification'
import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
export const projectRouter = router({
/**
@ -297,7 +298,8 @@ export const projectRouter = router({
)
.mutation(async ({ ctx, input }) => {
const { metadataJson, ...rest } = input
const project = await ctx.prisma.project.create({
const project = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.project.create({
data: {
...rest,
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
@ -305,17 +307,18 @@ export const projectRouter = router({
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'Project',
entityId: project.id,
entityId: created.id,
detailsJson: { title: input.title, roundId: input.roundId },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return created
})
return project
@ -457,16 +460,15 @@ export const projectRouter = router({
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Project',
entityId: id,
detailsJson: { ...data, status, metadataJson } as Prisma.InputJsonValue,
detailsJson: { ...data, status, metadataJson } as Record<string, unknown>,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project
@ -478,21 +480,26 @@ export const projectRouter = router({
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.delete({
const project = await ctx.prisma.$transaction(async (tx) => {
const target = await tx.project.findUniqueOrThrow({
where: { id: input.id },
select: { id: true, title: true },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'Project',
entityId: input.id,
detailsJson: { title: project.title },
detailsJson: { title: target.title },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return tx.project.delete({
where: { id: input.id },
})
})
return project
@ -559,15 +566,14 @@ export const projectRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'IMPORT',
entityType: 'Project',
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return result
@ -617,40 +623,42 @@ export const projectRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const updated = await ctx.prisma.project.updateMany({
// Fetch matching projects BEFORE update so notifications match actually-updated records
const [projects, round] = await Promise.all([
ctx.prisma.project.findMany({
where: {
id: { in: input.ids },
roundId: input.roundId,
},
select: { id: true, title: true },
}),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
}),
])
const matchingIds = projects.map((p) => p.id)
const updated = await ctx.prisma.project.updateMany({
where: {
id: { in: matchingIds },
roundId: input.roundId,
},
data: { status: input.status },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS',
entityType: 'Project',
detailsJson: { ids: input.ids, roundId: input.roundId, status: input.status },
detailsJson: { ids: matchingIds, roundId: input.roundId, status: input.status, count: updated.count },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Get round details including configured notification type
const [projects, round] = await Promise.all([
input.ids.length > 0
? ctx.prisma.project.findMany({
where: { id: { in: input.ids } },
select: { id: true, title: true },
})
: Promise.resolve([]),
ctx.prisma.round.findUnique({
where: { id: input.roundId },
select: { name: true, entryNotificationType: true, program: { select: { name: true } } },
}),
])
// Helper to get notification title based on type
const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = {

View File

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

View File

@ -4,6 +4,7 @@ import { getWhatsAppProvider, getWhatsAppProviderType } from '@/lib/whatsapp'
import { listAvailableModels, testOpenAIConnection, isReasoningModel } from '@/lib/openai'
import { getAIUsageStats, getCurrentMonthCost, formatCost } from '@/server/utils/ai-usage'
import { clearStorageProviderCache } from '@/lib/storage'
import { logAudit } from '../utils/audit'
/**
* Categorize an OpenAI model for display
@ -124,8 +125,8 @@ export const settingsRouter = router({
}
// Audit log (don't log actual value for secrets)
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_SETTING',
entityType: 'SystemSettings',
@ -136,7 +137,6 @@ export const settingsRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return setting
@ -193,15 +193,14 @@ export const settingsRouter = router({
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_SETTINGS_BATCH',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return results
@ -357,8 +356,8 @@ export const settingsRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_NOTIFICATION_PREFERENCES',
entityType: 'User',
@ -369,7 +368,6 @@ export const settingsRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return user

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import { router, protectedProcedure, adminProcedure, superAdminProcedure, public
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
import { hashPassword, validatePassword } from '@/lib/password'
import { attachAvatarUrls } from '@/server/utils/avatar-url'
import { logAudit } from '@/server/utils/audit'
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
@ -146,9 +147,10 @@ export const userRouter = router({
})
}
// Audit log before deletion
await ctx.prisma.auditLog.create({
data: {
// Wrap audit + deletion in a transaction
await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE_OWN_ACCOUNT',
entityType: 'User',
@ -156,13 +158,12 @@ export const userRouter = router({
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Delete the user
await ctx.prisma.user.delete({
await tx.user.delete({
where: { id: ctx.user.id },
})
})
return { success: true }
}),
@ -288,24 +289,26 @@ export const userRouter = router({
})
}
const user = await ctx.prisma.user.create({
const user = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.user.create({
data: {
...input,
status: 'INVITED',
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'CREATE',
entityType: 'User',
entityId: user.id,
entityId: created.id,
detailsJson: { email: input.email, role: input.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return created
})
return user
@ -348,14 +351,14 @@ export const userRouter = router({
})
}
const user = await ctx.prisma.user.update({
const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id },
data,
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'User',
@ -363,13 +366,12 @@ export const userRouter = router({
detailsJson: data,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Track role change specifically
if (data.role && data.role !== targetUser.role) {
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'ROLE_CHANGED',
entityType: 'User',
@ -377,10 +379,12 @@ export const userRouter = router({
detailsJson: { previousRole: targetUser.role, newRole: data.role },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
}).catch(() => {})
})
}
return updated
})
return user
}),
@ -398,21 +402,27 @@ export const userRouter = router({
})
}
const user = await ctx.prisma.user.delete({
const user = await ctx.prisma.$transaction(async (tx) => {
// Fetch user data before deletion for the audit log
const target = await tx.user.findUniqueOrThrow({
where: { id: input.id },
select: { email: true },
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'DELETE',
entityType: 'User',
entityId: input.id,
detailsJson: { email: user.email },
detailsJson: { email: target.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return tx.user.delete({
where: { id: input.id },
})
})
return user
@ -490,15 +500,14 @@ export const userRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_CREATE',
entityType: 'User',
detailsJson: { count: created.count, skipped, duplicatesInInput },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
// Auto-send invitation emails to newly created users
@ -534,15 +543,14 @@ export const userRouter = router({
// Audit log for assignments if any were created
if (assignmentsCreated > 0) {
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_ASSIGN',
entityType: 'Assignment',
detailsJson: { count: assignmentsCreated, context: 'invitation_pre_assignment' },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
}
@ -692,8 +700,8 @@ export const userRouter = router({
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'SEND_INVITATION',
entityType: 'User',
@ -701,7 +709,6 @@ export const userRouter = router({
detailsJson: { email: user.email },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true, email: user.email }
@ -770,15 +777,14 @@ export const userRouter = router({
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'BULK_SEND_INVITATIONS',
entityType: 'User',
detailsJson: { sent, errors },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { sent, skipped: input.userIds.length - users.length, errors }
@ -810,7 +816,8 @@ export const userRouter = router({
const userTags = input.expertiseTags || []
const mergedTags = [...new Set([...adminTags, ...userTags])]
const user = await ctx.prisma.user.update({
const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id: ctx.user.id },
data: {
name: input.name,
@ -824,9 +831,8 @@ export const userRouter = router({
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'COMPLETE_ONBOARDING',
entityType: 'User',
@ -834,7 +840,9 @@ export const userRouter = router({
detailsJson: { name: input.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return updated
})
return user
@ -901,8 +909,9 @@ export const userRouter = router({
// Hash the password
const passwordHash = await hashPassword(input.password)
// Update user with new password
const user = await ctx.prisma.user.update({
// Update user with new password + audit in transaction
const user = await ctx.prisma.$transaction(async (tx) => {
const updated = await tx.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
@ -911,9 +920,8 @@ export const userRouter = router({
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'PASSWORD_SET',
entityType: 'User',
@ -921,7 +929,9 @@ export const userRouter = router({
detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return updated
})
return { success: true, email: user.email }
@ -982,8 +992,9 @@ export const userRouter = router({
// Hash the new password
const passwordHash = await hashPassword(input.newPassword)
// Update user with new password
await ctx.prisma.user.update({
// Update user with new password + audit in transaction
await ctx.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: ctx.user.id },
data: {
passwordHash,
@ -991,9 +1002,8 @@ export const userRouter = router({
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'PASSWORD_CHANGED',
entityType: 'User',
@ -1001,7 +1011,7 @@ export const userRouter = router({
detailsJson: { timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
})
return { success: true }
@ -1040,8 +1050,8 @@ export const userRouter = router({
// The actual email is sent through NextAuth's email provider
// Audit log (without user ID since this is public)
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: ctx.prisma,
userId: null, // No authenticated user
action: 'REQUEST_PASSWORD_RESET',
entityType: 'User',
@ -1049,7 +1059,6 @@ export const userRouter = router({
detailsJson: { email: input.email, timestamp: new Date().toISOString() },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }

View File

@ -24,7 +24,6 @@ export interface ScoreBreakdown {
bioMatch: number
workloadBalance: number
countryMatch: number
aiBoost: number
}
export interface AssignmentScore {
@ -367,7 +366,6 @@ export async function getSmartSuggestions(options: {
bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
aiBoost: 0,
},
reasoning,
matchingTags,
@ -490,7 +488,6 @@ export async function getMentorSuggestionsForProject(
bioMatch: bioScore,
workloadBalance: workloadScore,
countryMatch: countryScore,
aiBoost: 0,
},
reasoning,
matchingTags,

View File

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

View File

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