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:
2026-02-05 21:09:06 +01:00
parent 8d0979e649
commit 002a9dbfc3
34 changed files with 1688 additions and 1782 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,44 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure } from '../trpc'
import { generateLogoKey, type StorageProviderType } from '@/lib/storage'
import {
getStorageProviderWithType,
createStorageProvider,
generateLogoKey,
getContentType,
isValidImageType,
type StorageProviderType,
} from '@/lib/storage'
getImageUploadUrl,
confirmImageUpload,
getImageUrl,
deleteImage,
type ImageUploadConfig,
} from '../utils/image-upload'
type LogoSelect = {
logoKey: string | null
logoProvider: string | null
}
const logoConfig: ImageUploadConfig<LogoSelect> = {
label: 'logo',
generateKey: generateLogoKey,
findCurrent: (prisma, entityId) =>
prisma.project.findUnique({
where: { id: entityId },
select: { logoKey: true, logoProvider: true },
}),
getImageKey: (record) => record.logoKey,
getProviderType: (record) =>
(record.logoProvider as StorageProviderType) || 's3',
setImage: (prisma, entityId, key, providerType) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: key, logoProvider: providerType },
}),
clearImage: (prisma, entityId) =>
prisma.project.update({
where: { id: entityId },
data: { logoKey: null, logoProvider: null },
}),
auditEntityType: 'Project',
auditFieldName: 'logoKey',
}
export const logoRouter = router({
/**
@@ -23,11 +53,6 @@ export const logoRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Validate content type
if (!isValidImageType(input.contentType)) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid image type. Allowed: JPEG, PNG, GIF, WebP' })
}
// Verify project exists
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
@@ -38,17 +63,12 @@ export const logoRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
}
const key = generateLogoKey(input.projectId, input.fileName)
const contentType = getContentType(input.fileName)
const { provider, providerType } = await getStorageProviderWithType()
const uploadUrl = await provider.getUploadUrl(key, contentType)
return {
uploadUrl,
key,
providerType, // Return so client can pass it back on confirm
}
return getImageUploadUrl(
input.projectId,
input.fileName,
input.contentType,
generateLogoKey
)
}),
/**
@@ -63,38 +83,22 @@ export const logoRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
// Use the provider that was used for upload
const provider = createStorageProvider(input.providerType)
const exists = await provider.objectExists(input.key)
if (!exists) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Upload not found. Please try uploading again.' })
}
// Delete old logo if exists (from its original provider)
const currentProject = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { logoKey: true, logoProvider: true },
})
if (currentProject?.logoKey) {
try {
const oldProvider = createStorageProvider(
(currentProject.logoProvider as StorageProviderType) || 's3'
)
await oldProvider.deleteObject(currentProject.logoKey)
} catch (error) {
// Log but don't fail if old logo deletion fails
console.warn('Failed to delete old logo:', error)
await confirmImageUpload(
ctx.prisma,
logoConfig,
input.projectId,
input.key,
input.providerType,
{
userId: ctx.user.id,
ip: ctx.ip,
userAgent: ctx.userAgent,
}
}
)
// Update project with new logo key and provider
const project = await ctx.prisma.project.update({
// Return the updated project fields to match original API contract
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
data: {
logoKey: input.key,
logoProvider: input.providerType,
},
select: {
id: true,
logoKey: true,
@@ -102,23 +106,6 @@ export const logoRouter = router({
},
})
// Audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'UPDATE',
entityType: 'Project',
entityId: input.projectId,
detailsJson: {
field: 'logoKey',
newValue: input.key,
provider: input.providerType,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return project
}),
@@ -128,21 +115,7 @@ export const logoRouter = router({
getUrl: adminProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { logoKey: true, logoProvider: true },
})
if (!project?.logoKey) {
return null
}
// Use the provider that was used when the file was stored
const providerType = (project.logoProvider as StorageProviderType) || 's3'
const provider = createStorageProvider(providerType)
const url = await provider.getDownloadUrl(project.logoKey)
return url
return getImageUrl(ctx.prisma, logoConfig, input.projectId)
}),
/**
@@ -151,46 +124,10 @@ export const logoRouter = router({
delete: adminProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ ctx, input }) => {
const project = await ctx.prisma.project.findUnique({
where: { id: input.projectId },
select: { logoKey: true, logoProvider: true },
return deleteImage(ctx.prisma, logoConfig, input.projectId, {
userId: ctx.user.id,
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,
NotificationTypes,
} from '../services/in-app-notification'
import { logAudit } from '@/server/utils/audit'
export const mentorRouter = router({
/**
@@ -118,52 +119,54 @@ export const mentorRouter = router({
where: { id: input.mentorId },
})
// Create assignment
const assignment = await ctx.prisma.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId: input.mentorId,
method: input.method,
assignedBy: ctx.user.id,
aiConfidenceScore: input.aiConfidenceScore,
expertiseMatchScore: input.expertiseMatchScore,
aiReasoning: input.aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
// Create assignment + audit log in transaction
const assignment = await ctx.prisma.$transaction(async (tx) => {
const created = await tx.mentorAssignment.create({
data: {
projectId: input.projectId,
mentorId: input.mentorId,
method: input.method,
assignedBy: ctx.user.id,
aiConfidenceScore: input.aiConfidenceScore,
expertiseMatchScore: input.expertiseMatchScore,
aiReasoning: input.aiReasoning,
},
include: {
mentor: {
select: {
id: true,
name: true,
email: true,
expertiseTags: true,
},
},
project: {
select: {
id: true,
title: true,
},
},
},
project: {
select: {
id: true,
title: true,
},
},
},
})
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'MENTOR_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
entityId: created.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
projectTitle: created.project.title,
mentorId: input.mentorId,
mentorName: assignment.mentor.name,
mentorName: created.mentor.name,
method: input.method,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
return created
})
// Get team lead info for mentor notification
@@ -292,23 +295,22 @@ export const mentorRouter = router({
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'MENTOR_AUTO_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId,
mentorName: assignment.mentor.name,
method,
aiConfidenceScore,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_AUTO_ASSIGN',
entityType: 'MentorAssignment',
entityId: assignment.id,
detailsJson: {
projectId: input.projectId,
projectTitle: assignment.project.title,
mentorId,
mentorName: assignment.mentor.name,
method,
aiConfidenceScore,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
// Get team lead info for mentor notification
@@ -371,13 +373,10 @@ export const mentorRouter = router({
})
}
await ctx.prisma.mentorAssignment.delete({
where: { projectId: input.projectId },
})
// Create audit log
await ctx.prisma.auditLog.create({
data: {
// Delete assignment + audit log in transaction
await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'MENTOR_UNASSIGN',
entityType: 'MentorAssignment',
@@ -390,7 +389,11 @@ export const mentorRouter = router({
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
},
})
await tx.mentorAssignment.delete({
where: { projectId: input.projectId },
})
})
return { success: true }
@@ -518,20 +521,19 @@ export const mentorRouter = router({
}
// Create audit log
await ctx.prisma.auditLog.create({
data: {
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
assigned,
failed,
useAI: input.useAI,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'MENTOR_BULK_ASSIGN',
entityType: 'Round',
entityId: input.roundId,
detailsJson: {
assigned,
failed,
useAI: input.useAI,
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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