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 # 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 ## 1. UI/UX Design Review
**Reviewer**: UI/UX Design Reviewer **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 ### 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` **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` **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) **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` **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 ### 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**: **Files**:
- `src/components/layouts/jury-nav.tsx` - Uses `bg-primary/10 text-primary` - `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` **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` **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` **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 ### 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` **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` **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` **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 ### 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) **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` **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` **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` **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 ### 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` **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` **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` **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` **File**: `src/app/(auth)/onboarding/page.tsx`
@ -246,7 +252,7 @@ The multi-step onboarding uses:
### 1.6 Accessibility ### 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` **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` **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 **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 ### 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` **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` **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` **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 ### 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` **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` **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` **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` **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` **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` **Files**: `prisma/schema.prisma:348-353`
@ -431,7 +437,7 @@ The `Round` model has both `submissionDeadline` and `submissionEndDate` with a c
### Indexing ### 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` **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` **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` **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` **Files**: `prisma/schema.prisma:590-612`
@ -479,7 +485,7 @@ The `evaluation.submit` mutation queries grace periods with `roundId + userId +
### Query Optimization ### 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` **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` **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` **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` **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` **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` **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 **Files**: All routers
@ -563,7 +569,7 @@ Every mutation `await`s the audit log creation, meaning the user's response is d
### Data Integrity ### 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` **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 **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` **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` **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` **Files**: `prisma/schema.prisma:1213`
@ -629,7 +635,7 @@ The `winnerProjectId` field has a relation to `Project` with `onDelete: SetNull`
### Performance ### 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` **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` **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` **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 Duplicate Code
#### 4.1.1 `toProjectWithRelations` duplicated across 3 AI services #### 4.1.1 `toProjectWithRelations` duplicated across 3 AI services — **DONE**
**Files:** **Files:**
- `src/server/services/ai-tagging.ts:144` - `src/server/services/ai-tagging.ts:144`
- `src/server/services/ai-filtering.ts:281` - `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:** **Files:**
- `src/server/services/ai-assignment.ts:273-377` - `src/server/services/ai-assignment.ts:273-377`
- `src/server/services/ai-filtering.ts:425-565` - `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:** **Files:**
- `src/server/routers/avatar.ts` (187 lines) - `src/server/routers/avatar.ts` (187 lines)
- `src/server/routers/logo.ts` (196 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:** **Files:**
- `src/components/layouts/jury-nav.tsx` - `src/components/layouts/jury-nav.tsx`
- `src/components/layouts/mentor-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:** **Files:**
- `src/server/services/ai-assignment.ts:514-534` (`calculateExpertiseScore`) - `src/server/services/ai-assignment.ts:514-534` (`calculateExpertiseScore`)
- `src/server/services/smart-assignment.ts:140-163` (`calculateTagOverlapScore`) - `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:** **Files:**
- `src/server/utils/audit.ts` - Shared `logAudit()` utility (wraps in try-catch) - `src/server/utils/audit.ts` - Shared `logAudit()` utility (wraps in try-catch)
- Used in: `src/server/routers/filtering.ts`, `src/server/routers/specialAward.ts` - 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:** **Files:**
- `src/server/services/ai-tagging.ts:456-551` (`batchTagProjects`) - `src/server/services/ai-tagging.ts:456-551` (`batchTagProjects`)
- `src/server/services/ai-tagging.ts:558-655` (`batchTagProgramProjects`) - `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 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` **File:** `src/lib/minio.ts:115-120`
The `getObjectInfo` function is exported but never imported or used anywhere in the codebase. 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` **File:** `src/lib/storage/index.ts:136-138`
Exported but never imported anywhere. Image size validation is not performed server-side. 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` **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. 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` **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. 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` **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`. 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:** **Files:**
- `src/server/services/ai-tagging.ts:456-551` - `src/server/services/ai-tagging.ts:456-551`
- `src/server/services/ai-tagging.ts:558-655` - `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` **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. 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 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:** **Files:**
- `src/lib/minio.ts` - Direct MinIO client with helper functions - `src/lib/minio.ts` - Direct MinIO client with helper functions
- `src/lib/storage/` - Provider-based abstraction (S3Provider wraps minio.ts, LocalProvider) - `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` **File:** `src/server/routers/user.ts:246,256`
Debug logging for a simple user fetch query: 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:** **Files:**
- `src/server/services/ai-tagging.ts` - 22 console.log statements - `src/server/services/ai-tagging.ts` - 22 console.log statements
- `src/server/services/ai-filtering.ts` - 5 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 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` **File:** `src/server/routers/applicant.ts:659`
```typescript ```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` **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. 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 | | Package | Evidence | Recommendation | Status |
|---------|----------|----------------| |---------|----------|----------------|--------|
| `twilio` (v5.4.0) | Never imported; Twilio provider uses raw `fetch()` | Remove from package.json | | `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 | | `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 | | `@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 ### 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` **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` **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` **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 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` **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` **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` **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` **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` **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 ### 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` **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 **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` **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` **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` **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` **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` **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` **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 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` **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` **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` **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` **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` **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) ### 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. 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. 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. 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. 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. 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. 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. 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). 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. 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. 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* *Reviewed by: Feature Proposer Agent*
*Date: 2026-02-05* *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. 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) ### Quick Wins (Low Effort, High Impact)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import {
notifyProjectTeam, notifyProjectTeam,
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
export const mentorRouter = router({ export const mentorRouter = router({
/** /**
@ -118,52 +119,54 @@ export const mentorRouter = router({
where: { id: input.mentorId }, where: { id: input.mentorId },
}) })
// Create assignment // Create assignment + audit log in transaction
const assignment = await ctx.prisma.mentorAssignment.create({ const assignment = await ctx.prisma.$transaction(async (tx) => {
data: { const created = await tx.mentorAssignment.create({
projectId: input.projectId, data: {
mentorId: input.mentorId, projectId: input.projectId,
method: input.method, mentorId: input.mentorId,
assignedBy: ctx.user.id, method: input.method,
aiConfidenceScore: input.aiConfidenceScore, assignedBy: ctx.user.id,
expertiseMatchScore: input.expertiseMatchScore, aiConfidenceScore: input.aiConfidenceScore,
aiReasoning: input.aiReasoning, expertiseMatchScore: input.expertiseMatchScore,
}, aiReasoning: input.aiReasoning,
include: { },
mentor: { include: {
select: { mentor: {
id: true, select: {
name: true, id: true,
email: true, name: true,
expertiseTags: true, email: true,
expertiseTags: true,
},
},
project: {
select: {
id: true,
title: true,
},
}, },
}, },
project: { })
select: {
id: true,
title: true,
},
},
},
})
// Create audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'MENTOR_ASSIGN', action: 'MENTOR_ASSIGN',
entityType: 'MentorAssignment', entityType: 'MentorAssignment',
entityId: assignment.id, entityId: created.id,
detailsJson: { detailsJson: {
projectId: input.projectId, projectId: input.projectId,
projectTitle: assignment.project.title, projectTitle: created.project.title,
mentorId: input.mentorId, mentorId: input.mentorId,
mentorName: assignment.mentor.name, mentorName: created.mentor.name,
method: input.method, method: input.method,
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return created
}) })
// Get team lead info for mentor notification // Get team lead info for mentor notification
@ -292,23 +295,22 @@ export const mentorRouter = router({
}) })
// Create audit log // Create audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'MENTOR_AUTO_ASSIGN', action: 'MENTOR_AUTO_ASSIGN',
entityType: 'MentorAssignment', entityType: 'MentorAssignment',
entityId: assignment.id, entityId: assignment.id,
detailsJson: { detailsJson: {
projectId: input.projectId, projectId: input.projectId,
projectTitle: assignment.project.title, projectTitle: assignment.project.title,
mentorId, mentorId,
mentorName: assignment.mentor.name, mentorName: assignment.mentor.name,
method, method,
aiConfidenceScore, aiConfidenceScore,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
// Get team lead info for mentor notification // Get team lead info for mentor notification
@ -371,13 +373,10 @@ export const mentorRouter = router({
}) })
} }
await ctx.prisma.mentorAssignment.delete({ // Delete assignment + audit log in transaction
where: { projectId: input.projectId }, await ctx.prisma.$transaction(async (tx) => {
}) await logAudit({
prisma: tx,
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'MENTOR_UNASSIGN', action: 'MENTOR_UNASSIGN',
entityType: 'MentorAssignment', entityType: 'MentorAssignment',
@ -390,7 +389,11 @@ export const mentorRouter = router({
}, },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
await tx.mentorAssignment.delete({
where: { projectId: input.projectId },
})
}) })
return { success: true } return { success: true }
@ -518,20 +521,19 @@ export const mentorRouter = router({
} }
// Create audit log // Create audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN', action: 'MENTOR_BULK_ASSIGN',
entityType: 'Round', entityType: 'Round',
entityId: input.roundId, entityId: input.roundId,
detailsJson: { detailsJson: {
assigned, assigned,
failed, failed,
useAI: input.useAI, useAI: input.useAI,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}, },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
}) })
return { return {

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import {
NotificationTypes, NotificationTypes,
} from '../services/in-app-notification' } from '../services/in-app-notification'
import { normalizeCountryToCode } from '@/lib/countries' import { normalizeCountryToCode } from '@/lib/countries'
import { logAudit } from '../utils/audit'
export const projectRouter = router({ export const projectRouter = router({
/** /**
@ -297,25 +298,27 @@ export const projectRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { metadataJson, ...rest } = input const { metadataJson, ...rest } = input
const project = await ctx.prisma.project.create({ const project = await ctx.prisma.$transaction(async (tx) => {
data: { const created = await tx.project.create({
...rest, data: {
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined, ...rest,
status: 'SUBMITTED', metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
}, status: 'SUBMITTED',
}) },
})
// Audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'CREATE', action: 'CREATE',
entityType: 'Project', entityType: 'Project',
entityId: project.id, entityId: created.id,
detailsJson: { title: input.title, roundId: input.roundId }, detailsJson: { title: input.title, roundId: input.roundId },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return created
}) })
return project return project
@ -457,16 +460,15 @@ export const projectRouter = router({
} }
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'UPDATE', action: 'UPDATE',
entityType: 'Project', entityType: 'Project',
entityId: id, entityId: id,
detailsJson: { ...data, status, metadataJson } as Prisma.InputJsonValue, detailsJson: { ...data, status, metadataJson } as Record<string, unknown>,
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return project return project
@ -478,21 +480,26 @@ export const projectRouter = router({
delete: adminProcedure delete: adminProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.delete({ const project = await ctx.prisma.$transaction(async (tx) => {
where: { id: input.id }, const target = await tx.project.findUniqueOrThrow({
}) where: { id: input.id },
select: { id: true, title: true },
})
// Audit log await logAudit({
await ctx.prisma.auditLog.create({ prisma: tx,
data: {
userId: ctx.user.id, userId: ctx.user.id,
action: 'DELETE', action: 'DELETE',
entityType: 'Project', entityType: 'Project',
entityId: input.id, entityId: input.id,
detailsJson: { title: project.title }, detailsJson: { title: target.title },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
}, })
return tx.project.delete({
where: { id: input.id },
})
}) })
return project return project
@ -559,15 +566,14 @@ export const projectRouter = router({
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'IMPORT', action: 'IMPORT',
entityType: 'Project', entityType: 'Project',
detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported }, detailsJson: { programId: input.programId, roundId: input.roundId, count: result.imported },
ipAddress: ctx.ip, ipAddress: ctx.ip,
userAgent: ctx.userAgent, userAgent: ctx.userAgent,
},
}) })
return result return result
@ -617,40 +623,42 @@ export const projectRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// 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({ const updated = await ctx.prisma.project.updateMany({
where: { where: {
id: { in: input.ids }, id: { in: matchingIds },
roundId: input.roundId, roundId: input.roundId,
}, },
data: { status: input.status }, data: { status: input.status },
}) })
// Audit log // Audit log
await ctx.prisma.auditLog.create({ await logAudit({
data: { prisma: ctx.prisma,
userId: ctx.user.id, userId: ctx.user.id,
action: 'BULK_UPDATE_STATUS', action: 'BULK_UPDATE_STATUS',
entityType: 'Project', 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, ipAddress: ctx.ip,
userAgent: ctx.userAgent, 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 // Helper to get notification title based on type
const getNotificationTitle = (type: string): string => { const getNotificationTitle = (type: string): string => {
const titles: Record<string, string> = { const titles: Record<string, string> = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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