Compare commits

...

2 Commits

Author SHA1 Message Date
3e70de3a5a Add Anthropic API, test environment, remove locale settings
Feature 1: Anthropic API Integration
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option

Feature 2: Remove Locale Settings UI
- Strip Localization tab from admin settings
- Remove i18n settings from router inferCategory and getFeatureFlags
- Keep franc document language detection intact

Feature 3: Test Environment with Role Impersonation
- Add isTest field to User, Program, Project, Competition models
- Test environment service: create/teardown with realistic dummy data
- JWT-based impersonation for test users (@test.local emails)
- Impersonation banner with quick-switch between test roles
- Test environment panel in admin settings (SUPER_ADMIN only)
- Email redirect: @test.local emails routed to admin with [TEST] prefix
- Complete data isolation: 45+ isTest:false filters across platform
  - All global queries on User/Project/Program/Competition
  - AI services blocked from processing test data
  - Cron jobs skip test rounds/users
  - Analytics/exports exclude test data
  - Admin layout/pickers hide test programs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:28:07 +01:00
f42b452899 Add Anthropic API integration, remove locale settings UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m15s
Anthropic API:
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option
- Add provider field to AIUsageLog for cross-provider cost tracking

Locale Settings Removal:
- Strip Localization tab from admin settings (mobile + desktop)
- Remove i18n settings from router and feature flags
- Remove LOCALIZATION from SettingCategory enum
- Keep franc document language detection intact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:26:59 +01:00
62 changed files with 2083 additions and 983 deletions

40
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "mopc-platform", "name": "mopc-platform",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4", "@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2", "@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2", "@blocknote/mantine": "^0.46.2",
@@ -119,6 +120,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@anthropic-ai/sdk": {
"version": "0.78.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.78.0.tgz",
"integrity": "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@auth/core": { "node_modules/@auth/core": {
"version": "0.41.1", "version": "0.41.1",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
@@ -9277,6 +9298,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -13609,6 +13643,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.4.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",

View File

@@ -21,6 +21,7 @@
"test:e2e": "playwright test" "test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4", "@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2", "@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2", "@blocknote/mantine": "^0.46.2",

View File

@@ -0,0 +1,8 @@
-- Add isTest field to User, Program, Project, Competition for test environment isolation
ALTER TABLE "User" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Program" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Project" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Competition" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
-- Index for efficient test data filtering
CREATE INDEX "Competition_isTest_idx" ON "Competition"("isTest");

View File

@@ -0,0 +1,13 @@
-- Delete any existing LOCALIZATION settings
DELETE FROM "SystemSettings" WHERE category = 'LOCALIZATION';
-- Add provider field to AIUsageLog for cross-provider cost tracking
ALTER TABLE "AIUsageLog" ADD COLUMN "provider" TEXT;
-- Remove LOCALIZATION from SettingCategory enum
-- First create new enum without the value, then swap
CREATE TYPE "SettingCategory_new" AS ENUM ('AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP', 'AUDIT_CONFIG', 'DIGEST', 'ANALYTICS', 'INTEGRATIONS', 'COMMUNICATION', 'FEATURE_FLAGS');
ALTER TABLE "SystemSettings" ALTER COLUMN "category" TYPE "SettingCategory_new" USING ("category"::text::"SettingCategory_new");
ALTER TYPE "SettingCategory" RENAME TO "SettingCategory_old";
ALTER TYPE "SettingCategory_new" RENAME TO "SettingCategory";
DROP TYPE "SettingCategory_old";

View File

@@ -101,7 +101,6 @@ enum SettingCategory {
DEFAULTS DEFAULTS
WHATSAPP WHATSAPP
AUDIT_CONFIG AUDIT_CONFIG
LOCALIZATION
DIGEST DIGEST
ANALYTICS ANALYTICS
INTEGRATIONS INTEGRATIONS
@@ -351,6 +350,9 @@ model User {
preferredWorkload Int? preferredWorkload Int?
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string } availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
lastLoginAt DateTime? lastLoginAt DateTime?
@@ -495,6 +497,9 @@ model Program {
description String? description String?
settingsJson Json? @db.JsonB settingsJson Json? @db.JsonB
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -619,6 +624,9 @@ model Project {
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc. metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc. externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -907,7 +915,8 @@ model AIUsageLog {
entityId String? entityId String?
// What was used // What was used
model String // gpt-4o, gpt-4o-mini, o1, etc. model String // gpt-4o, gpt-4o-mini, o1, claude-sonnet-4-5, etc.
provider String? // openai, anthropic, litellm
promptTokens Int promptTokens Int
completionTokens Int completionTokens Int
totalTokens Int totalTokens Int
@@ -2090,6 +2099,9 @@ model Competition {
notifyOnDeadlineApproach Boolean @default(true) notifyOnDeadlineApproach Boolean @default(true)
deadlineReminderDays Int[] @default([7, 3, 1]) deadlineReminderDays Int[] @default([7, 3, 1])
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -2104,6 +2116,7 @@ model Competition {
@@index([programId]) @@index([programId])
@@index([status]) @@index([status])
@@index([isTest])
} }
model Round { model Round {

View File

@@ -68,8 +68,19 @@ export default function AssignmentsDashboardPage() {
if (!competition) { if (!competition) {
return ( return (
<div className="container mx-auto space-y-6 p-4 sm:p-6"> <div className="container mx-auto space-y-6 p-4 sm:p-6">
<p>Competition not found</p> <Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="font-medium">Competition not found</p>
<p className="text-sm text-muted-foreground mt-1">
The requested competition does not exist or you don&apos;t have access.
</p>
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
</CardContent>
</Card>
</div> </div>
) )
} }

View File

@@ -13,16 +13,34 @@ import type { Route } from 'next';
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) { export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
const params = use(paramsPromise); const params = use(paramsPromise);
const router = useRouter(); const router = useRouter();
const { data: competition } = trpc.competition.getById.useQuery({ const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery({
id: params.competitionId id: params.competitionId
}); });
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({ const { data: awards, isLoading, isError: isAwardsError } = trpc.specialAward.list.useQuery({
programId: competition?.programId programId: competition?.programId
}, { }, {
enabled: !!competition?.programId enabled: !!competition?.programId
}); });
if (isCompError || isAwardsError) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">Error Loading Awards</h1>
<p className="text-sm text-muted-foreground">
Could not load competition or awards data. Please try again.
</p>
</div>
</div>
</div>
);
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@@ -43,13 +43,13 @@ export default function DeliberationListPage({
participantUserIds: [] as string[] participantUserIds: [] as string[]
}); });
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery( const { data: sessions = [], isLoading, isError: isSessionsError } = trpc.deliberation.listSessions.useQuery(
{ competitionId: params.competitionId }, { competitionId: params.competitionId },
{ enabled: !!params.competitionId } { enabled: !!params.competitionId }
); );
// Get rounds for this competition // Get rounds for this competition
const { data: competition } = trpc.competition.getById.useQuery( const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery(
{ id: params.competitionId }, { id: params.competitionId },
{ enabled: !!params.competitionId } { enabled: !!params.competitionId }
); );
@@ -121,6 +121,24 @@ export default function DeliberationListPage({
return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>; return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
}; };
if (isCompError || isSessionsError) {
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Go back">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">Error Loading Deliberations</h1>
<p className="text-sm text-muted-foreground">
Could not load competition or deliberation data. Please try again.
</p>
</div>
</div>
</div>
);
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6 p-4 sm:p-6"> <div className="space-y-6 p-4 sm:p-6">

View File

@@ -48,6 +48,7 @@ import {
Loader2, Loader2,
Plus, Plus,
CalendarDays, CalendarDays,
Radio,
} from 'lucide-react' } from 'lucide-react'
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline' import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
@@ -435,6 +436,19 @@ export default function CompetitionDetailPage() {
<span className="truncate">{round.juryGroup.name}</span> <span className="truncate">{round.juryGroup.name}</span>
</div> </div>
)} )}
{/* Live Control link for LIVE_FINAL rounds */}
{round.roundType === 'LIVE_FINAL' && (
<Link
href={`/admin/competitions/${competitionId}/live/${round.id}` as Route}
onClick={(e) => e.stopPropagation()}
>
<Button size="sm" variant="outline" className="w-full text-xs gap-1.5">
<Radio className="h-3.5 w-3.5" />
Live Control
</Button>
</Link>
)}
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>

View File

@@ -1,10 +0,0 @@
import { redirect } from 'next/navigation'
export default async function MentorDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
redirect(`/admin/members/${id}`)
}

View File

@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function MentorsPage() {
redirect('/admin/members')
}

View File

@@ -30,7 +30,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
if (!editionId) { if (!editionId) {
const defaultEdition = await prisma.program.findFirst({ const defaultEdition = await prisma.program.findFirst({
where: { status: 'ACTIVE' }, where: { status: 'ACTIVE', isTest: false },
orderBy: { year: 'desc' }, orderBy: { year: 'desc' },
select: { id: true }, select: { id: true },
}) })
@@ -38,6 +38,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
if (!editionId) { if (!editionId) {
const anyEdition = await prisma.program.findFirst({ const anyEdition = await prisma.program.findFirst({
where: { isTest: false },
orderBy: { year: 'desc' }, orderBy: { year: 'desc' },
select: { id: true }, select: { id: true },
}) })

View File

@@ -19,7 +19,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { ArrowLeft, Pencil, Plus } from 'lucide-react' import { ArrowLeft, GraduationCap, Pencil, Plus } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils' import { formatDateOnly } from '@/lib/utils'
interface ProgramDetailPageProps { interface ProgramDetailPageProps {
@@ -65,12 +65,20 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</p> </p>
</div> </div>
</div> </div>
<Button variant="outline" asChild> <div className="flex items-center gap-2">
<Link href={`/admin/programs/${id}/edit`}> <Button variant="outline" asChild>
<Pencil className="mr-2 h-4 w-4" /> <Link href={`/admin/programs/${id}/mentorship` as Route}>
Edit <GraduationCap className="mr-2 h-4 w-4" />
</Link> Mentorship
</Button> </Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
</div> </div>
{program.description && ( {program.description && (

View File

@@ -40,7 +40,7 @@ import { formatDateOnly } from '@/lib/utils'
async function ProgramsContent() { async function ProgramsContent() {
const programs = await prisma.program.findMany({ const programs = await prisma.program.findMany({
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation where: { isTest: false },
include: { include: {
competitions: { competitions: {
include: { include: {

View File

@@ -205,14 +205,14 @@ export default function RoundsPage() {
} }
const startEditSettings = () => { const startEditSettings = () => {
if (!comp) return if (!comp || !compDetail) return
setEditingCompId(comp.id) setEditingCompId(comp.id)
setCompetitionEdits({ setCompetitionEdits({
name: comp.name, name: compDetail.name,
categoryMode: (comp as any).categoryMode, categoryMode: compDetail.categoryMode,
startupFinalistCount: (comp as any).startupFinalistCount, startupFinalistCount: compDetail.startupFinalistCount,
conceptFinalistCount: (comp as any).conceptFinalistCount, conceptFinalistCount: compDetail.conceptFinalistCount,
notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach, notifyOnDeadlineApproach: compDetail.notifyOnDeadlineApproach,
}) })
setSettingsOpen(true) setSettingsOpen(true)
} }

View File

@@ -12,6 +12,7 @@ export default async function AdminLayout({
// Fetch all editions (programs) for the edition selector // Fetch all editions (programs) for the edition selector
const editions = await prisma.program.findMany({ const editions = await prisma.program.findMany({
where: { isTest: false },
select: { select: {
id: true, id: true,
name: true, name: true,

View File

@@ -13,8 +13,10 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
const now = new Date() const now = new Date()
// Delete projects where isDraft=true AND draftExpiresAt has passed // Delete projects where isDraft=true AND draftExpiresAt has passed
// Exclude test projects — they are managed separately
const result = await prisma.project.deleteMany({ const result = await prisma.project.deleteMany({
where: { where: {
isTest: false,
isDraft: true, isDraft: true,
draftExpiresAt: { draftExpiresAt: {
lt: now, lt: now,

View File

@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import './globals.css' import './globals.css'
import { Providers } from './providers' import { Providers } from './providers'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
export const metadata: Metadata = { export const metadata: Metadata = {
title: { title: {
@@ -22,7 +23,10 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className="min-h-screen bg-background font-sans antialiased"> <body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers> <Providers>
<ImpersonationBanner />
{children}
</Providers>
<Toaster <Toaster
position="top-right" position="top-right"
toastOptions={{ toastOptions={{

View File

@@ -1,654 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Plus, Lock, Unlock, LockKeyhole, Loader2, Pencil, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
type SubmissionWindowManagerProps = {
competitionId: string
roundId: string
}
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [editingWindow, setEditingWindow] = useState<string | null>(null)
const [deletingWindow, setDeletingWindow] = useState<string | null>(null)
// Create form state
const [createForm, setCreateForm] = useState({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
graceHours: 0,
lockOnClose: true,
})
// Edit form state
const [editForm, setEditForm] = useState({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
graceHours: 0,
lockOnClose: true,
sortOrder: 1,
})
const utils = trpc.useUtils()
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
id: competitionId,
})
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window created')
setIsCreateOpen(false)
// Reset form
setCreateForm({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE',
graceHours: 0,
lockOnClose: true,
})
},
onError: (err) => toast.error(err.message),
})
const updateWindowMutation = trpc.round.updateSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window updated')
setEditingWindow(null)
},
onError: (err) => toast.error(err.message),
})
const deleteWindowMutation = trpc.round.deleteSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window deleted')
setDeletingWindow(null)
},
onError: (err) => toast.error(err.message),
})
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window opened')
},
onError: (err) => toast.error(err.message),
})
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window closed')
},
onError: (err) => toast.error(err.message),
})
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window locked')
},
onError: (err) => toast.error(err.message),
})
const handleCreateNameChange = (value: string) => {
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setCreateForm({ ...createForm, name: value, slug: autoSlug })
}
const handleEditNameChange = (value: string) => {
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setEditForm({ ...editForm, name: value, slug: autoSlug })
}
const handleCreate = () => {
if (!createForm.name || !createForm.slug) {
toast.error('Name and slug are required')
return
}
createWindowMutation.mutate({
competitionId,
name: createForm.name,
slug: createForm.slug,
roundNumber: createForm.roundNumber,
windowOpenAt: createForm.windowOpenAt ? new Date(createForm.windowOpenAt) : undefined,
windowCloseAt: createForm.windowCloseAt ? new Date(createForm.windowCloseAt) : undefined,
deadlinePolicy: createForm.deadlinePolicy,
graceHours: createForm.deadlinePolicy === 'GRACE' ? createForm.graceHours : undefined,
lockOnClose: createForm.lockOnClose,
})
}
const handleEdit = () => {
if (!editingWindow) return
if (!editForm.name || !editForm.slug) {
toast.error('Name and slug are required')
return
}
updateWindowMutation.mutate({
id: editingWindow,
name: editForm.name,
slug: editForm.slug,
roundNumber: editForm.roundNumber,
windowOpenAt: editForm.windowOpenAt ? new Date(editForm.windowOpenAt) : null,
windowCloseAt: editForm.windowCloseAt ? new Date(editForm.windowCloseAt) : null,
deadlinePolicy: editForm.deadlinePolicy,
graceHours: editForm.deadlinePolicy === 'GRACE' ? editForm.graceHours : null,
lockOnClose: editForm.lockOnClose,
sortOrder: editForm.sortOrder,
})
}
const handleDelete = () => {
if (!deletingWindow) return
deleteWindowMutation.mutate({ id: deletingWindow })
}
const openEditDialog = (window: any) => {
setEditForm({
name: window.name,
slug: window.slug,
roundNumber: window.roundNumber,
windowOpenAt: window.windowOpenAt ? new Date(window.windowOpenAt).toISOString().slice(0, 16) : '',
windowCloseAt: window.windowCloseAt ? new Date(window.windowCloseAt).toISOString().slice(0, 16) : '',
deadlinePolicy: window.deadlinePolicy ?? 'HARD_DEADLINE',
graceHours: window.graceHours ?? 0,
lockOnClose: window.lockOnClose ?? true,
sortOrder: window.sortOrder ?? 1,
})
setEditingWindow(window.id)
}
const formatDate = (date: Date | null | undefined) => {
if (!date) return 'Not set'
return format(new Date(date), 'MMM d, yyyy h:mm a')
}
const windows = competition?.submissionWindows ?? []
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-base">Submission Windows</CardTitle>
<p className="text-sm text-muted-foreground">
File upload windows for this round
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-1" />
Create Window
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="create-name">Window Name</Label>
<Input
id="create-name"
placeholder="e.g., Round 1 Submissions"
value={createForm.name}
onChange={(e) => handleCreateNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-slug">Slug</Label>
<Input
id="create-slug"
placeholder="e.g., round-1-submissions"
value={createForm.slug}
onChange={(e) => setCreateForm({ ...createForm, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-roundNumber">Round Number</Label>
<Input
id="create-roundNumber"
type="number"
min={1}
value={createForm.roundNumber}
onChange={(e) => setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-windowOpenAt">Window Open At</Label>
<Input
id="create-windowOpenAt"
type="datetime-local"
value={createForm.windowOpenAt}
onChange={(e) => setCreateForm({ ...createForm, windowOpenAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-windowCloseAt">Window Close At</Label>
<Input
id="create-windowCloseAt"
type="datetime-local"
value={createForm.windowCloseAt}
onChange={(e) => setCreateForm({ ...createForm, windowCloseAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-deadlinePolicy">Deadline Policy</Label>
<Select
value={createForm.deadlinePolicy}
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
setCreateForm({ ...createForm, deadlinePolicy: value })
}
>
<SelectTrigger id="create-deadlinePolicy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
<SelectItem value="GRACE">Grace Period</SelectItem>
</SelectContent>
</Select>
</div>
{createForm.deadlinePolicy === 'GRACE' && (
<div className="space-y-2">
<Label htmlFor="create-graceHours">Grace Hours</Label>
<Input
id="create-graceHours"
type="number"
min={0}
value={createForm.graceHours}
onChange={(e) => setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
id="create-lockOnClose"
checked={createForm.lockOnClose}
onCheckedChange={(checked) => setCreateForm({ ...createForm, lockOnClose: checked })}
/>
<Label htmlFor="create-lockOnClose" className="cursor-pointer">
Lock window on close
</Label>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleCreate}
disabled={createWindowMutation.isPending}
>
{createWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Create
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
Loading windows...
</div>
) : windows.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No submission windows yet. Create one to enable file uploads.
</div>
) : (
<div className="space-y-2">
{windows.map((window) => {
const isPending = !window.windowOpenAt
const isOpen = window.windowOpenAt && !window.windowCloseAt
const isClosed = window.windowCloseAt && !window.isLocked
const isLocked = window.isLocked
return (
<div
key={window.id}
className="flex flex-col gap-3 border rounded-lg p-3"
>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">{window.name}</p>
{isPending && (
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
Pending
</Badge>
)}
{isOpen && (
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
Open
</Badge>
)}
{isClosed && (
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
Closed
</Badge>
)}
{isLocked && (
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
Locked
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Round {window.roundNumber}</span>
<span></span>
<span>{window._count.fileRequirements} requirements</span>
<span></span>
<span>{window._count.projectFiles} files</span>
</div>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Open: {formatDate(window.windowOpenAt)}</span>
<span></span>
<span>Close: {formatDate(window.windowCloseAt)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap">
<Button
size="sm"
variant="ghost"
onClick={() => openEditDialog(window)}
className="h-8 px-2"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeletingWindow(window.id)}
className="h-8 px-2 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
{isPending && (
<Button
size="sm"
variant="outline"
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
disabled={openWindowMutation.isPending}
>
{openWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Unlock className="h-3 w-3 mr-1" />
)}
Open
</Button>
)}
{isOpen && (
<Button
size="sm"
variant="outline"
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
disabled={closeWindowMutation.isPending}
>
{closeWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Lock className="h-3 w-3 mr-1" />
)}
Close
</Button>
)}
{isClosed && (
<Button
size="sm"
variant="outline"
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
disabled={lockWindowMutation.isPending}
>
{lockWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<LockKeyhole className="h-3 w-3 mr-1" />
)}
Lock
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingWindow} onOpenChange={(open) => !open && setEditingWindow(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Window Name</Label>
<Input
id="edit-name"
placeholder="e.g., Round 1 Submissions"
value={editForm.name}
onChange={(e) => handleEditNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-slug">Slug</Label>
<Input
id="edit-slug"
placeholder="e.g., round-1-submissions"
value={editForm.slug}
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-roundNumber">Round Number</Label>
<Input
id="edit-roundNumber"
type="number"
min={1}
value={editForm.roundNumber}
onChange={(e) => setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-windowOpenAt">Window Open At</Label>
<Input
id="edit-windowOpenAt"
type="datetime-local"
value={editForm.windowOpenAt}
onChange={(e) => setEditForm({ ...editForm, windowOpenAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-windowCloseAt">Window Close At</Label>
<Input
id="edit-windowCloseAt"
type="datetime-local"
value={editForm.windowCloseAt}
onChange={(e) => setEditForm({ ...editForm, windowCloseAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-deadlinePolicy">Deadline Policy</Label>
<Select
value={editForm.deadlinePolicy}
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
setEditForm({ ...editForm, deadlinePolicy: value })
}
>
<SelectTrigger id="edit-deadlinePolicy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
<SelectItem value="GRACE">Grace Period</SelectItem>
</SelectContent>
</Select>
</div>
{editForm.deadlinePolicy === 'GRACE' && (
<div className="space-y-2">
<Label htmlFor="edit-graceHours">Grace Hours</Label>
<Input
id="edit-graceHours"
type="number"
min={0}
value={editForm.graceHours}
onChange={(e) => setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
id="edit-lockOnClose"
checked={editForm.lockOnClose}
onCheckedChange={(checked) => setEditForm({ ...editForm, lockOnClose: checked })}
/>
<Label htmlFor="edit-lockOnClose" className="cursor-pointer">
Lock window on close
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sortOrder">Sort Order</Label>
<Input
id="edit-sortOrder"
type="number"
min={1}
value={editForm.sortOrder}
onChange={(e) => setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setEditingWindow(null)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleEdit}
disabled={updateWindowMutation.isPending}
>
{updateWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Save Changes
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deletingWindow} onOpenChange={(open) => !open && setDeletingWindow(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Submission Window</DialogTitle>
<DialogDescription>
Are you sure you want to delete this submission window? This action cannot be undone.
{(windows.find(w => w.id === deletingWindow)?._count?.projectFiles ?? 0) > 0 && (
<span className="block mt-2 text-destructive font-medium">
Warning: This window has uploaded files and cannot be deleted until they are removed.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button
variant="outline"
onClick={() => setDeletingWindow(null)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteWindowMutation.isPending}
>
{deleteWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -226,7 +226,8 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{navigation.map((item) => { {navigation.map((item) => {
const isActive = const isActive =
pathname === item.href || pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href)) (item.href !== '/admin' && pathname.startsWith(item.href)) ||
(item.href === '/admin/rounds' && pathname.startsWith('/admin/competitions'))
return ( return (
<div key={item.name}> <div key={item.name}>
<Link <Link
@@ -258,12 +259,24 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
Administration Administration
</p> </p>
{dynamicAdminNav.map((item) => { {dynamicAdminNav.map((item) => {
const isDisabled = item.name === 'Apply Page' && !currentEdition?.id
let isActive = pathname.startsWith(item.href) let isActive = pathname.startsWith(item.href)
if (item.activeMatch) { if (item.activeMatch) {
isActive = pathname.includes(item.activeMatch) isActive = pathname.includes(item.activeMatch)
} else if (item.activeExclude && pathname.includes(item.activeExclude)) { } else if (item.activeExclude && pathname.includes(item.activeExclude)) {
isActive = false isActive = false
} }
if (isDisabled) {
return (
<span
key={item.name}
className="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium opacity-50 pointer-events-none text-muted-foreground"
>
<item.icon className="h-4 w-4 text-muted-foreground" />
{item.name}
</span>
)
}
return ( return (
<Link <Link
key={item.name} key={item.name}

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import { useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod' import { z } from 'zod'
@@ -36,6 +37,7 @@ const formSchema = z.object({
ai_model: z.string(), ai_model: z.string(),
ai_send_descriptions: z.boolean(), ai_send_descriptions: z.boolean(),
openai_api_key: z.string().optional(), openai_api_key: z.string().optional(),
anthropic_api_key: z.string().optional(),
openai_base_url: z.string().optional(), openai_base_url: z.string().optional(),
}) })
@@ -48,6 +50,7 @@ interface AISettingsFormProps {
ai_model?: string ai_model?: string
ai_send_descriptions?: string ai_send_descriptions?: string
openai_api_key?: string openai_api_key?: string
anthropic_api_key?: string
openai_base_url?: string openai_base_url?: string
} }
} }
@@ -63,12 +66,29 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
ai_model: settings.ai_model || 'gpt-4o', ai_model: settings.ai_model || 'gpt-4o',
ai_send_descriptions: settings.ai_send_descriptions === 'true', ai_send_descriptions: settings.ai_send_descriptions === 'true',
openai_api_key: '', openai_api_key: '',
anthropic_api_key: '',
openai_base_url: settings.openai_base_url || '', openai_base_url: settings.openai_base_url || '',
}, },
}) })
const watchProvider = form.watch('ai_provider') const watchProvider = form.watch('ai_provider')
const isLiteLLM = watchProvider === 'litellm' const isLiteLLM = watchProvider === 'litellm'
const isAnthropic = watchProvider === 'anthropic'
const prevProviderRef = useRef(settings.ai_provider || 'openai')
// Auto-reset model when provider changes
useEffect(() => {
if (watchProvider !== prevProviderRef.current) {
prevProviderRef.current = watchProvider
if (watchProvider === 'anthropic') {
form.setValue('ai_model', 'claude-sonnet-4-5-20250514')
} else if (watchProvider === 'openai') {
form.setValue('ai_model', 'gpt-4o')
} else if (watchProvider === 'litellm') {
form.setValue('ai_model', '')
}
}
}, [watchProvider, form])
// Fetch available models from OpenAI API (skip for LiteLLM — no models.list support) // Fetch available models from OpenAI API (skip for LiteLLM — no models.list support)
const { const {
@@ -119,6 +139,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
if (data.openai_api_key && data.openai_api_key.trim()) { if (data.openai_api_key && data.openai_api_key.trim()) {
settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key }) settingsToUpdate.push({ key: 'openai_api_key', value: data.openai_api_key })
} }
if (data.anthropic_api_key && data.anthropic_api_key.trim()) {
settingsToUpdate.push({ key: 'anthropic_api_key', value: data.anthropic_api_key })
}
// Save base URL (empty string clears it) // Save base URL (empty string clears it)
settingsToUpdate.push({ key: 'openai_base_url', value: data.openai_base_url?.trim() || '' }) settingsToUpdate.push({ key: 'openai_base_url', value: data.openai_base_url?.trim() || '' })
@@ -139,6 +162,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
) )
const categoryLabels: Record<string, string> = { const categoryLabels: Record<string, string> = {
'claude-4.5': 'Claude 4.5 Series (Latest)',
'claude-4': 'Claude 4 Series',
'claude-3.5': 'Claude 3.5 Series',
'gpt-5+': 'GPT-5+ Series (Latest)', 'gpt-5+': 'GPT-5+ Series (Latest)',
'gpt-4o': 'GPT-4o Series', 'gpt-4o': 'GPT-4o Series',
'gpt-4': 'GPT-4 Series', 'gpt-4': 'GPT-4 Series',
@@ -147,7 +173,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
other: 'Other Models', other: 'Other Models',
} }
const categoryOrder = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other'] const categoryOrder = ['claude-4.5', 'claude-4', 'claude-3.5', 'gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
return ( return (
<Form {...form}> <Form {...form}>
@@ -187,13 +213,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="openai">OpenAI (API Key)</SelectItem> <SelectItem value="openai">OpenAI (API Key)</SelectItem>
<SelectItem value="anthropic">Anthropic (Claude API)</SelectItem>
<SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem> <SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<FormDescription> <FormDescription>
{field.value === 'litellm' {field.value === 'litellm'
? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription' ? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription'
: 'Direct OpenAI API access using your API key'} : field.value === 'anthropic'
? 'Direct Anthropic API access using Claude models'
: 'Direct OpenAI API access using your API key'}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -211,37 +240,71 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</Alert> </Alert>
)} )}
<FormField {isAnthropic && (
control={form.control} <Alert>
name="openai_api_key" <Info className="h-4 w-4" />
render={({ field }) => ( <AlertDescription>
<FormItem> <strong>Anthropic Claude Mode</strong> AI calls use the Anthropic Messages API.
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'API Key'}</FormLabel> Claude Opus models include extended thinking for deeper analysis.
<FormControl> JSON responses are validated with automatic retry.
<Input </AlertDescription>
type="password" </Alert>
placeholder={isLiteLLM )}
? 'Optional — leave blank for default'
: (settings.openai_api_key ? '••••••••' : 'Enter API key')} {isAnthropic ? (
{...field} <FormField
/> control={form.control}
</FormControl> name="anthropic_api_key"
<FormDescription> render={({ field }) => (
{isLiteLLM <FormItem>
? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.' <FormLabel>Anthropic API Key</FormLabel>
: 'Your OpenAI API key. Leave blank to keep the existing key.'} <FormControl>
</FormDescription> <Input
<FormMessage /> type="password"
</FormItem> placeholder={settings.anthropic_api_key ? '••••••••' : 'Enter Anthropic API key'}
)} {...field}
/> />
</FormControl>
<FormDescription>
Your Anthropic API key. Leave blank to keep the existing key.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField
control={form.control}
name="openai_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'OpenAI API Key'}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={isLiteLLM
? 'Optional — leave blank for default'
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
{...field}
/>
</FormControl>
<FormDescription>
{isLiteLLM
? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.'
: 'Your OpenAI API key. Leave blank to keep the existing key.'}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField <FormField
control={form.control} control={form.control}
name="openai_base_url" name="openai_base_url"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'}</FormLabel> <FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : isAnthropic ? 'Anthropic Base URL (Optional)' : 'API Base URL (Optional)'}</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder={isLiteLLM ? 'http://localhost:4000' : 'https://api.openai.com/v1'} placeholder={isLiteLLM ? 'http://localhost:4000' : 'https://api.openai.com/v1'}
@@ -255,6 +318,10 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
<code className="text-xs bg-muted px-1 rounded">http://localhost:4000</code>{' '} <code className="text-xs bg-muted px-1 rounded">http://localhost:4000</code>{' '}
or your server address. or your server address.
</> </>
) : isAnthropic ? (
<>
Custom base URL for Anthropic API proxy or gateway. Leave blank for default Anthropic API.
</>
) : ( ) : (
<> <>
Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI. Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI.
@@ -288,7 +355,42 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)} )}
</div> </div>
{isLiteLLM || modelsData?.manualEntry ? ( {isAnthropic ? (
// Anthropic: fetch models from server (hardcoded list)
modelsLoading ? (
<Skeleton className="h-10 w-full" />
) : modelsData?.success && modelsData.models && modelsData.models.length > 0 ? (
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select Claude model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categoryOrder
.filter((cat) => groupedModels?.[cat]?.length)
.map((category) => (
<SelectGroup key={category}>
<SelectLabel className="text-xs font-semibold text-muted-foreground">
{categoryLabels[category] || category}
</SelectLabel>
{groupedModels?.[category]?.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
) : (
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
placeholder="claude-sonnet-4-5-20250514"
/>
)
) : isLiteLLM || modelsData?.manualEntry ? (
<Input <Input
value={field.value} value={field.value}
onChange={(e) => field.onChange(e.target.value)} onChange={(e) => field.onChange(e.target.value)}
@@ -341,7 +443,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</Select> </Select>
)} )}
<FormDescription> <FormDescription>
{isLiteLLM ? ( {isAnthropic ? (
form.watch('ai_model')?.includes('opus') ? (
<span className="flex items-center gap-1 text-amber-600">
<SlidersHorizontal className="h-3 w-3" />
Opus model includes extended thinking for deeper analysis
</span>
) : (
'Anthropic Claude model to use for AI features'
)
) : isLiteLLM ? (
<> <>
Enter the model ID with the{' '} Enter the model ID with the{' '}
<code className="text-xs bg-muted px-1 rounded">chatgpt/</code> prefix. <code className="text-xs bg-muted px-1 rounded">chatgpt/</code> prefix.

View File

@@ -23,14 +23,15 @@ import {
Newspaper, Newspaper,
BarChart3, BarChart3,
ShieldAlert, ShieldAlert,
Globe,
Webhook, Webhook,
MessageCircle, MessageCircle,
FlaskConical,
} from 'lucide-react' } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { AnimatedCard } from '@/components/shared/animated-container' import { AnimatedCard } from '@/components/shared/animated-container'
import { AISettingsForm } from './ai-settings-form' import { AISettingsForm } from './ai-settings-form'
import { AIUsageCard } from './ai-usage-card' import { AIUsageCard } from './ai-usage-card'
import { TestEnvironmentPanel } from './test-environment-panel'
import { BrandingSettingsForm } from './branding-settings-form' import { BrandingSettingsForm } from './branding-settings-form'
import { EmailSettingsForm } from './email-settings-form' import { EmailSettingsForm } from './email-settings-form'
import { StorageSettingsForm } from './storage-settings-form' import { StorageSettingsForm } from './storage-settings-form'
@@ -158,11 +159,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
'whatsapp_provider', 'whatsapp_provider',
]) ])
const localizationSettings = getSettingsByKeys([
'localization_enabled_locales',
'localization_default_locale',
])
return ( return (
<> <>
<Tabs defaultValue="defaults" className="space-y-6"> <Tabs defaultValue="defaults" className="space-y-6">
@@ -176,10 +172,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Palette className="h-4 w-4" /> <Palette className="h-4 w-4" />
Branding Branding
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="localization" className="gap-2 shrink-0">
<Globe className="h-4 w-4" />
Locale
</TabsTrigger>
{isSuperAdmin && ( {isSuperAdmin && (
<TabsTrigger value="email" className="gap-2 shrink-0"> <TabsTrigger value="email" className="gap-2 shrink-0">
<Mail className="h-4 w-4" /> <Mail className="h-4 w-4" />
@@ -236,6 +228,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Webhooks Webhooks
</Link> </Link>
)} )}
{isSuperAdmin && (
<TabsTrigger value="testenv" className="gap-2 shrink-0">
<FlaskConical className="h-4 w-4" />
Test Env
</TabsTrigger>
)}
</TabsList> </TabsList>
<div className="lg:flex lg:gap-8"> <div className="lg:flex lg:gap-8">
@@ -253,10 +251,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Palette className="h-4 w-4" /> <Palette className="h-4 w-4" />
Branding Branding
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="localization" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Globe className="h-4 w-4" />
Locale
</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<div> <div>
@@ -333,6 +327,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Webhooks Webhooks
<ExternalLink className="ml-auto h-3 w-3 opacity-50" /> <ExternalLink className="ml-auto h-3 w-3 opacity-50" />
</Link> </Link>
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5 mt-1">
<TabsTrigger value="testenv" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<FlaskConical className="h-4 w-4" />
Test Env
</TabsTrigger>
</TabsList>
</div> </div>
)} )}
</nav> </nav>
@@ -510,22 +510,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</AnimatedCard> </AnimatedCard>
</TabsContent> </TabsContent>
<TabsContent value="localization" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Localization</CardTitle>
<CardDescription>
Configure language and locale settings
</CardDescription>
</CardHeader>
<CardContent>
<LocalizationSettingsSection settings={localizationSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
{isSuperAdmin && ( {isSuperAdmin && (
<TabsContent value="whatsapp" className="space-y-6"> <TabsContent value="whatsapp" className="space-y-6">
<AnimatedCard> <AnimatedCard>
@@ -543,6 +527,28 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</AnimatedCard> </AnimatedCard>
</TabsContent> </TabsContent>
)} )}
{isSuperAdmin && (
<TabsContent value="testenv" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FlaskConical className="h-5 w-5" />
Test Environment
</CardTitle>
<CardDescription>
Create a sandboxed test competition with dummy data for testing all roles and workflows.
Fully isolated from production data.
</CardDescription>
</CardHeader>
<CardContent>
<TestEnvironmentPanel />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
)}
</div>{/* end content area */} </div>{/* end content area */}
</div>{/* end lg:flex */} </div>{/* end lg:flex */}
</Tabs> </Tabs>
@@ -858,66 +864,3 @@ function WhatsAppSettingsSection({ settings }: { settings: Record<string, string
) )
} }
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
const mutation = useSettingsMutation()
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
const toggleLocale = (locale: string) => {
const current = new Set(enabledLocales)
if (current.has(locale)) {
if (current.size <= 1) {
toast.error('At least one locale must be enabled')
return
}
current.delete(locale)
} else {
current.add(locale)
}
mutation.mutate({
key: 'localization_enabled_locales',
value: Array.from(current).join(','),
})
}
return (
<div className="space-y-4">
<div className="space-y-3">
<Label className="text-sm font-medium">Enabled Languages</Label>
<div className="space-y-2">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">EN</span>
<span className="text-sm text-muted-foreground">English</span>
</div>
<Checkbox
checked={enabledLocales.includes('en')}
onCheckedChange={() => toggleLocale('en')}
disabled={mutation.isPending}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">FR</span>
<span className="text-sm text-muted-foreground">Fran&ccedil;ais</span>
</div>
<Checkbox
checked={enabledLocales.includes('fr')}
onCheckedChange={() => toggleLocale('fr')}
disabled={mutation.isPending}
/>
</div>
</div>
</div>
<SettingSelect
label="Default Locale"
description="The default language for new users"
settingKey="localization_default_locale"
value={settings.localization_default_locale || 'en'}
options={[
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Fran\u00e7ais' },
]}
/>
</div>
)
}

View File

@@ -0,0 +1,297 @@
'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
FlaskConical,
Plus,
Trash2,
ExternalLink,
Loader2,
Users,
UserCog,
CheckCircle2,
AlertTriangle,
} from 'lucide-react'
import type { UserRole } from '@prisma/client'
const ROLE_LABELS: Record<string, string> = {
JURY_MEMBER: 'Jury Member',
APPLICANT: 'Applicant',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
AWARD_MASTER: 'Award Master',
PROGRAM_ADMIN: 'Program Admin',
}
const ROLE_COLORS: Record<string, string> = {
JURY_MEMBER: 'bg-blue-100 text-blue-800',
APPLICANT: 'bg-green-100 text-green-800',
MENTOR: 'bg-purple-100 text-purple-800',
OBSERVER: 'bg-orange-100 text-orange-800',
AWARD_MASTER: 'bg-yellow-100 text-yellow-800',
PROGRAM_ADMIN: 'bg-red-100 text-red-800',
}
const ROLE_LANDING: Record<string, string> = {
JURY_MEMBER: '/jury',
APPLICANT: '/applicant',
MENTOR: '/mentor',
OBSERVER: '/observer',
AWARD_MASTER: '/admin',
PROGRAM_ADMIN: '/admin',
}
export function TestEnvironmentPanel() {
const { update } = useSession()
const router = useRouter()
const utils = trpc.useUtils()
const { data: status, isLoading } = trpc.testEnvironment.status.useQuery()
const createMutation = trpc.testEnvironment.create.useMutation({
onSuccess: () => utils.testEnvironment.status.invalidate(),
})
const tearDownMutation = trpc.testEnvironment.tearDown.useMutation({
onSuccess: () => utils.testEnvironment.status.invalidate(),
})
const [confirmText, setConfirmText] = useState('')
const [tearDownOpen, setTearDownOpen] = useState(false)
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)
}
// No test environment — show creation card
if (!status?.active) {
return (
<div className="space-y-4">
<div className="rounded-lg border-2 border-dashed p-8 text-center">
<FlaskConical className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-semibold">No Test Environment</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-md mx-auto">
Create a sandboxed test competition with dummy users, projects, jury assignments,
and partial evaluations. All test data is fully isolated from production.
</p>
<Button
className="mt-6"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating test environment...
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
Create Test Competition
</>
)}
</Button>
{createMutation.isError && (
<p className="mt-3 text-sm text-destructive">
{createMutation.error.message}
</p>
)}
</div>
</div>
)
}
// Test environment is active
const { competition, rounds, users, emailRedirect } = status
// Group users by role for impersonation cards
const roleGroups = users.reduce(
(acc, u) => {
const role = u.role as string
if (!acc[role]) acc[role] = []
acc[role].push(u)
return acc
},
{} as Record<string, typeof users>
)
async function handleImpersonate(userId: string, role: UserRole) {
await update({ impersonateUserId: userId })
router.push((ROLE_LANDING[role] || '/admin') as any)
router.refresh()
}
function handleTearDown() {
if (confirmText !== 'DELETE TEST') return
tearDownMutation.mutate(undefined, {
onSuccess: () => {
setTearDownOpen(false)
setConfirmText('')
},
})
}
return (
<div className="space-y-6">
{/* Status header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
<CheckCircle2 className="mr-1 h-3 w-3" />
Test Active
</Badge>
<span className="text-sm text-muted-foreground">
{competition.name}
</span>
</div>
<Button variant="outline" size="sm" asChild>
<a href={`/admin/competitions/${competition.id}`} target="_blank" rel="noopener">
View Competition
<ExternalLink className="ml-1.5 h-3 w-3" />
</a>
</Button>
</div>
{/* Quick stats */}
<div className="grid grid-cols-3 gap-4 text-center">
<div className="rounded-lg border p-3">
<p className="text-2xl font-bold">{rounds.length}</p>
<p className="text-xs text-muted-foreground">Rounds</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-2xl font-bold">{users.length}</p>
<p className="text-xs text-muted-foreground">Test Users</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-2xl font-bold truncate text-sm font-mono">
{emailRedirect || '—'}
</p>
<p className="text-xs text-muted-foreground">Email Redirect</p>
</div>
</div>
{/* Impersonation section */}
<div>
<div className="flex items-center gap-2 mb-3">
<UserCog className="h-4 w-4 text-muted-foreground" />
<h4 className="text-sm font-semibold">Impersonate Test User</h4>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{Object.entries(roleGroups).map(([role, roleUsers]) => (
<Card key={role} className="overflow-hidden">
<CardHeader className="py-2 px-3">
<div className="flex items-center justify-between">
<Badge variant="secondary" className={ROLE_COLORS[role] || ''}>
{ROLE_LABELS[role] || role}
</Badge>
<span className="text-xs text-muted-foreground">
{roleUsers.length} user{roleUsers.length !== 1 ? 's' : ''}
</span>
</div>
</CardHeader>
<CardContent className="py-2 px-3 space-y-1.5">
{roleUsers.slice(0, 3).map((u) => (
<button
key={u.id}
onClick={() => handleImpersonate(u.id, u.role as UserRole)}
className="flex items-center justify-between w-full rounded-md px-2 py-1.5 text-sm hover:bg-muted transition-colors text-left"
>
<span className="truncate">{u.name || u.email}</span>
<span className="text-xs text-muted-foreground shrink-0 ml-2">
Impersonate
</span>
</button>
))}
{roleUsers.length > 3 && (
<p className="text-xs text-muted-foreground px-2">
+{roleUsers.length - 3} more (switch via banner)
</p>
)}
</CardContent>
</Card>
))}
</div>
</div>
{/* Tear down */}
<div className="border-t pt-4">
<AlertDialog open={tearDownOpen} onOpenChange={setTearDownOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Tear Down Test Environment
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Destroy Test Environment
</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete ALL test data: users, projects, competitions,
assignments, evaluations, and files. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2 py-2">
<p className="text-sm font-medium">
Type <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">DELETE TEST</code> to confirm:
</p>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="DELETE TEST"
className="font-mono"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmText('')}>
Cancel
</AlertDialogCancel>
<Button
variant="destructive"
onClick={handleTearDown}
disabled={confirmText !== 'DELETE TEST' || tearDownMutation.isPending}
>
{tearDownMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Tearing down...
</>
) : (
'Destroy Test Environment'
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ChevronDown, LogOut, UserCog } from 'lucide-react'
import type { UserRole } from '@prisma/client'
const ROLE_LABELS: Record<string, string> = {
JURY_MEMBER: 'Jury Member',
APPLICANT: 'Applicant',
MENTOR: 'Mentor',
OBSERVER: 'Observer',
AWARD_MASTER: 'Award Master',
PROGRAM_ADMIN: 'Program Admin',
SUPER_ADMIN: 'Super Admin',
}
const ROLE_LANDING: Record<string, string> = {
JURY_MEMBER: '/jury',
APPLICANT: '/applicant',
MENTOR: '/mentor',
OBSERVER: '/observer',
AWARD_MASTER: '/admin',
PROGRAM_ADMIN: '/admin',
SUPER_ADMIN: '/admin',
}
export function ImpersonationBanner() {
const { data: session, update } = useSession()
const router = useRouter()
const [switching, setSwitching] = useState(false)
// Only fetch test users when impersonating (realRole check happens server-side)
const { data: testEnv } = trpc.testEnvironment.status.useQuery(undefined, {
enabled: !!session?.user?.isImpersonating,
staleTime: 60_000,
})
if (!session?.user?.isImpersonating) return null
const currentRole = session.user.role
const currentName = session.user.impersonatedName || session.user.name || 'Unknown'
// Group available test users by role (exclude currently impersonated user)
const availableUsers = testEnv?.active
? testEnv.users.filter((u) => u.id !== session.user.id)
: []
const roleGroups = availableUsers.reduce(
(acc, u) => {
const role = u.role as string
if (!acc[role]) acc[role] = []
acc[role].push(u)
return acc
},
{} as Record<string, typeof availableUsers>
)
async function handleSwitch(userId: string, role: UserRole) {
setSwitching(true)
await update({ impersonateUserId: userId })
router.push((ROLE_LANDING[role] || '/admin') as any)
router.refresh()
setSwitching(false)
}
async function handleStopImpersonation() {
setSwitching(true)
await update({ stopImpersonation: true })
router.push('/admin/settings' as any)
router.refresh()
setSwitching(false)
}
return (
<div className="fixed top-0 left-0 right-0 z-50 bg-amber-500 text-amber-950 shadow-md">
<div className="mx-auto flex items-center justify-between px-4 py-1.5 text-sm font-medium">
<div className="flex items-center gap-2">
<UserCog className="h-4 w-4" />
<span>
Viewing as <strong>{currentName}</strong>{' '}
<span className="rounded bg-amber-600/30 px-1.5 py-0.5 text-xs font-semibold">
{ROLE_LABELS[currentRole] || currentRole}
</span>
</span>
</div>
<div className="flex items-center gap-2">
{/* Quick-switch dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
disabled={switching}
>
Switch Role
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{Object.entries(roleGroups).map(([role, users]) => (
<div key={role}>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{ROLE_LABELS[role] || role}
</DropdownMenuLabel>
{users.map((u) => (
<DropdownMenuItem
key={u.id}
onClick={() => handleSwitch(u.id, u.role as UserRole)}
disabled={switching}
>
<span className="truncate">{u.name || u.email}</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Return to admin */}
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 bg-amber-600/20 text-amber-950 hover:bg-amber-600/40"
onClick={handleStopImpersonation}
disabled={switching}
>
<LogOut className="h-3 w-3" />
Return to Admin
</Button>
</div>
</div>
</div>
)
}

View File

@@ -10,6 +10,11 @@ declare module 'next-auth' {
name?: string | null name?: string | null
role: UserRole role: UserRole
mustSetPassword?: boolean mustSetPassword?: boolean
// Impersonation fields
isImpersonating?: boolean
realUserId?: string
realRole?: UserRole
impersonatedName?: string | null
} }
} }
@@ -24,6 +29,12 @@ declare module '@auth/core/jwt' {
id: string id: string
role: UserRole role: UserRole
mustSetPassword?: boolean mustSetPassword?: boolean
// Impersonation fields
impersonatedUserId?: string
impersonatedRole?: UserRole
impersonatedName?: string | null
realUserId?: string
realRole?: UserRole
} }
} }

View File

@@ -190,7 +190,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
], ],
callbacks: { callbacks: {
...authConfig.callbacks, ...authConfig.callbacks,
async jwt({ token, user, trigger }) { async jwt({ token, user, trigger, session: sessionUpdate }) {
// Initial sign in // Initial sign in
if (user) { if (user) {
token.id = user.id as string token.id = user.id as string
@@ -198,15 +198,48 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.mustSetPassword = user.mustSetPassword token.mustSetPassword = user.mustSetPassword
} }
// On session update, refresh from database // On session update
if (trigger === 'update') { if (trigger === 'update') {
const dbUser = await prisma.user.findUnique({ // Handle impersonation request
where: { id: token.id as string }, if (sessionUpdate?.impersonateUserId) {
select: { role: true, mustSetPassword: true }, const testUser = await prisma.user.findUnique({
}) where: { id: sessionUpdate.impersonateUserId },
if (dbUser) { select: { id: true, name: true, email: true, role: true, isTest: true },
token.role = dbUser.role })
token.mustSetPassword = dbUser.mustSetPassword // Only allow impersonating test users with @test.local emails
if (testUser?.isTest && testUser.email.endsWith('@test.local')) {
// Preserve original identity (only set once in case of quick-switch)
if (!token.realUserId) {
token.realUserId = token.id as string
token.realRole = token.role as UserRole
}
token.id = testUser.id
token.role = testUser.role
token.impersonatedUserId = testUser.id
token.impersonatedRole = testUser.role
token.impersonatedName = testUser.name
}
}
// Handle stop impersonation
else if (sessionUpdate?.stopImpersonation && token.realUserId) {
token.id = token.realUserId
token.role = token.realRole!
delete token.impersonatedUserId
delete token.impersonatedRole
delete token.impersonatedName
delete token.realUserId
delete token.realRole
}
// Normal session refresh (only when not impersonating)
else if (!token.impersonatedUserId) {
const dbUser = await prisma.user.findUnique({
where: { id: token.id as string },
select: { role: true, mustSetPassword: true },
})
if (dbUser) {
token.role = dbUser.role
token.mustSetPassword = dbUser.mustSetPassword
}
} }
} }
@@ -217,6 +250,15 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
session.user.id = token.id as string session.user.id = token.id as string
session.user.role = token.role as UserRole session.user.role = token.role as UserRole
session.user.mustSetPassword = token.mustSetPassword as boolean | undefined session.user.mustSetPassword = token.mustSetPassword as boolean | undefined
// Impersonation state
session.user.isImpersonating = !!token.impersonatedUserId
if (token.realUserId) {
session.user.realUserId = token.realUserId as string
session.user.realRole = token.realRole as UserRole
}
if (token.impersonatedName !== undefined) {
session.user.impersonatedName = token.impersonatedName as string | null
}
} }
return session return session
}, },

View File

@@ -7,6 +7,32 @@ let cachedTransporter: Transporter | null = null
let cachedConfigHash = '' let cachedConfigHash = ''
let cachedFrom = '' let cachedFrom = ''
/**
* Resolve test email recipients: @test.local emails are redirected
* to the admin's email (from test_email_redirect setting) and
* the subject is prefixed with [TEST]. Real emails are never affected.
*/
async function resolveTestEmailRecipient(
to: string,
subject: string
): Promise<{ to: string; subject: string }> {
if (!to.endsWith('@test.local')) {
return { to, subject }
}
const redirect = await prisma.systemSettings.findUnique({
where: { key: 'test_email_redirect' },
select: { value: true },
})
if (redirect?.value) {
return {
to: redirect.value,
subject: `[TEST] ${subject}`,
}
}
// No redirect configured — suppress the email entirely
return { to: '', subject }
}
/** /**
* Get SMTP transporter using database settings with env var fallback. * Get SMTP transporter using database settings with env var fallback.
* Caches the transporter and rebuilds it when settings change. * Caches the transporter and rebuilds it when settings change.
@@ -47,12 +73,31 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
} }
// Create new transporter // Create new transporter
cachedTransporter = nodemailer.createTransport({ const rawTransporter = nodemailer.createTransport({
host, host,
port: parseInt(port), port: parseInt(port),
secure: port === '465', secure: port === '465',
auth: { user, pass }, auth: { user, pass },
}) })
// Wrap sendMail to auto-redirect @test.local emails
const originalSendMail = rawTransporter.sendMail.bind(rawTransporter)
rawTransporter.sendMail = async function (mailOptions: any) {
if (mailOptions.to && typeof mailOptions.to === 'string') {
const resolved = await resolveTestEmailRecipient(
mailOptions.to,
mailOptions.subject || ''
)
if (!resolved.to) {
// Suppress email entirely (no redirect configured for test)
return { messageId: 'suppressed-test-email' }
}
mailOptions = { ...mailOptions, to: resolved.to, subject: resolved.subject }
}
return originalSendMail(mailOptions)
} as any
cachedTransporter = rawTransporter
cachedConfigHash = configHash cachedConfigHash = configHash
cachedFrom = from cachedFrom = from

View File

@@ -1,10 +1,36 @@
import OpenAI from 'openai' import OpenAI from 'openai'
import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions' import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'
import Anthropic from '@anthropic-ai/sdk'
import { prisma } from './prisma' import { prisma } from './prisma'
// Hardcoded Claude model list (Anthropic API doesn't expose a models.list endpoint for all users)
export const ANTHROPIC_CLAUDE_MODELS = [
'claude-opus-4-5-20250514',
'claude-sonnet-4-5-20250514',
'claude-haiku-3-5-20241022',
'claude-opus-4-20250514',
'claude-sonnet-4-20250514',
] as const
/**
* AI client type returned by getOpenAI().
* Both the OpenAI SDK and the Anthropic adapter satisfy this interface.
* All AI services only use .chat.completions.create(), so this is safe.
*/
export type AIClient = OpenAI | AnthropicClientAdapter
type AnthropicClientAdapter = {
__isAnthropicAdapter: true
chat: {
completions: {
create(params: ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion>
}
}
}
// OpenAI client singleton with lazy initialization // OpenAI client singleton with lazy initialization
const globalForOpenAI = globalThis as unknown as { const globalForOpenAI = globalThis as unknown as {
openai: OpenAI | undefined openai: AIClient | undefined
openaiInitialized: boolean openaiInitialized: boolean
} }
@@ -12,15 +38,17 @@ const globalForOpenAI = globalThis as unknown as {
/** /**
* Get the configured AI provider from SystemSettings. * Get the configured AI provider from SystemSettings.
* Returns 'openai' (default) or 'litellm' (ChatGPT subscription proxy). * Returns 'openai' (default), 'litellm' (ChatGPT subscription proxy), or 'anthropic' (Claude API).
*/ */
export async function getConfiguredProvider(): Promise<'openai' | 'litellm'> { export async function getConfiguredProvider(): Promise<'openai' | 'litellm' | 'anthropic'> {
try { try {
const setting = await prisma.systemSettings.findUnique({ const setting = await prisma.systemSettings.findUnique({
where: { key: 'ai_provider' }, where: { key: 'ai_provider' },
}) })
const value = setting?.value || 'openai' const value = setting?.value || 'openai'
return value === 'litellm' ? 'litellm' : 'openai' if (value === 'litellm') return 'litellm'
if (value === 'anthropic') return 'anthropic'
return 'openai'
} catch { } catch {
return 'openai' return 'openai'
} }
@@ -219,6 +247,20 @@ async function getOpenAIApiKey(): Promise<string | null> {
} }
} }
/**
* Get Anthropic API key from SystemSettings
*/
async function getAnthropicApiKey(): Promise<string | null> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'anthropic_api_key' },
})
return setting?.value || process.env.ANTHROPIC_API_KEY || null
} catch {
return process.env.ANTHROPIC_API_KEY || null
}
}
/** /**
* Get custom base URL for OpenAI-compatible providers. * Get custom base URL for OpenAI-compatible providers.
* Supports OpenRouter, Together AI, Groq, local models, etc. * Supports OpenRouter, Together AI, Groq, local models, etc.
@@ -265,15 +307,165 @@ async function createOpenAIClient(): Promise<OpenAI | null> {
} }
/** /**
* Get the OpenAI client singleton * Check if a model is a Claude Opus model (supports extended thinking).
* Returns null if API key is not configured
*/ */
export async function getOpenAI(): Promise<OpenAI | null> { function isClaudeOpusModel(model: string): boolean {
return model.toLowerCase().includes('opus')
}
/**
* Create an Anthropic adapter that wraps the Anthropic SDK behind the
* same `.chat.completions.create()` surface as OpenAI. This allows all
* AI service files to work with zero changes.
*/
async function createAnthropicAdapter(): Promise<AnthropicClientAdapter | null> {
const apiKey = await getAnthropicApiKey()
if (!apiKey) {
console.warn('Anthropic API key not configured')
return null
}
const baseURL = await getBaseURL()
const anthropic = new Anthropic({
apiKey,
...(baseURL ? { baseURL } : {}),
})
if (baseURL) {
console.log(`[Anthropic] Using custom base URL: ${baseURL}`)
}
return {
__isAnthropicAdapter: true,
chat: {
completions: {
async create(params: ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion> {
// Extract system messages → Anthropic's system parameter
const systemMessages: string[] = []
const userAssistantMessages: Anthropic.MessageParam[] = []
for (const msg of params.messages) {
const content = typeof msg.content === 'string' ? msg.content : ''
if (msg.role === 'system' || msg.role === 'developer') {
systemMessages.push(content)
} else {
userAssistantMessages.push({
role: msg.role === 'assistant' ? 'assistant' : 'user',
content,
})
}
}
// Ensure messages start with a user message (Anthropic requirement)
if (userAssistantMessages.length === 0 || userAssistantMessages[0].role !== 'user') {
userAssistantMessages.unshift({ role: 'user', content: 'Hello' })
}
// Determine max_tokens (required by Anthropic, default 16384)
const maxTokens = params.max_tokens ?? params.max_completion_tokens ?? 16384
// Build Anthropic request
const anthropicParams: Anthropic.MessageCreateParamsNonStreaming = {
model: params.model,
max_tokens: maxTokens,
messages: userAssistantMessages,
...(systemMessages.length > 0 ? { system: systemMessages.join('\n\n') } : {}),
}
// Add temperature if present (Anthropic supports 0-1)
if (params.temperature !== undefined && params.temperature !== null) {
anthropicParams.temperature = params.temperature
}
// Extended thinking for Opus models
if (isClaudeOpusModel(params.model)) {
anthropicParams.thinking = { type: 'enabled', budget_tokens: Math.min(8192, maxTokens - 1) }
}
// Call Anthropic API
let response = await anthropic.messages.create(anthropicParams)
// Extract text from response (skip thinking blocks)
let responseText = response.content
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
.map((block) => block.text)
.join('')
// JSON retry: if response_format was set but response isn't valid JSON
const wantsJson = params.response_format && 'type' in params.response_format && params.response_format.type === 'json_object'
if (wantsJson && responseText) {
try {
JSON.parse(responseText)
} catch {
// Retry once with explicit JSON instruction
const retryMessages = [...userAssistantMessages]
const lastIdx = retryMessages.length - 1
if (lastIdx >= 0 && retryMessages[lastIdx].role === 'user') {
retryMessages[lastIdx] = {
...retryMessages[lastIdx],
content: retryMessages[lastIdx].content + '\n\nIMPORTANT: You MUST respond with valid JSON only. No markdown, no extra text, just a JSON object or array.',
}
}
const retryParams: Anthropic.MessageCreateParamsNonStreaming = {
...anthropicParams,
messages: retryMessages,
}
response = await anthropic.messages.create(retryParams)
responseText = response.content
.filter((block): block is Anthropic.TextBlock => block.type === 'text')
.map((block) => block.text)
.join('')
}
}
// Normalize response to OpenAI shape
return {
id: response.id,
object: 'chat.completion' as const,
created: Math.floor(Date.now() / 1000),
model: response.model,
choices: [
{
index: 0,
message: {
role: 'assistant' as const,
content: responseText || null,
refusal: null,
},
finish_reason: response.stop_reason === 'end_turn' || response.stop_reason === 'stop_sequence' ? 'stop' : response.stop_reason === 'max_tokens' ? 'length' : 'stop',
logprobs: null,
},
],
usage: {
prompt_tokens: response.usage.input_tokens,
completion_tokens: response.usage.output_tokens,
total_tokens: response.usage.input_tokens + response.usage.output_tokens,
prompt_tokens_details: undefined as any,
completion_tokens_details: undefined as any,
},
}
},
},
},
}
}
/**
* Get the AI client singleton.
* Returns an OpenAI client or an Anthropic adapter (both expose .chat.completions.create()).
* Returns null if the API key is not configured.
*/
export async function getOpenAI(): Promise<AIClient | null> {
if (globalForOpenAI.openaiInitialized) { if (globalForOpenAI.openaiInitialized) {
return globalForOpenAI.openai || null return globalForOpenAI.openai || null
} }
const client = await createOpenAIClient() const provider = await getConfiguredProvider()
const client = provider === 'anthropic'
? await createAnthropicAdapter()
: await createOpenAIClient()
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
globalForOpenAI.openai = client || undefined globalForOpenAI.openai = client || undefined
@@ -298,10 +490,13 @@ export function resetOpenAIClient(): void {
export async function isOpenAIConfigured(): Promise<boolean> { export async function isOpenAIConfigured(): Promise<boolean> {
const provider = await getConfiguredProvider() const provider = await getConfiguredProvider()
if (provider === 'litellm') { if (provider === 'litellm') {
// LiteLLM just needs a base URL configured
const baseURL = await getBaseURL() const baseURL = await getBaseURL()
return !!baseURL return !!baseURL
} }
if (provider === 'anthropic') {
const apiKey = await getAnthropicApiKey()
return !!apiKey
}
const apiKey = await getOpenAIApiKey() const apiKey = await getOpenAIApiKey()
return !!apiKey return !!apiKey
} }
@@ -327,6 +522,18 @@ export async function listAvailableModels(): Promise<{
} }
} }
// Anthropic: return hardcoded Claude model list
if (provider === 'anthropic') {
const apiKey = await getAnthropicApiKey()
if (!apiKey) {
return { success: false, error: 'Anthropic API key not configured' }
}
return {
success: true,
models: [...ANTHROPIC_CLAUDE_MODELS],
}
}
const client = await getOpenAI() const client = await getOpenAI()
if (!client) { if (!client) {
@@ -336,7 +543,7 @@ export async function listAvailableModels(): Promise<{
} }
} }
const response = await client.models.list() const response = await (client as OpenAI).models.list()
const chatModels = response.data const chatModels = response.data
.filter((m) => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3') || m.id.includes('o4')) .filter((m) => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3') || m.id.includes('o4'))
.map((m) => m.id) .map((m) => m.id)
@@ -367,14 +574,16 @@ export async function validateModel(modelId: string): Promise<{
if (!client) { if (!client) {
return { return {
valid: false, valid: false,
error: 'OpenAI API key not configured', error: 'AI API key not configured',
} }
} }
// Try a minimal completion with the model using correct parameters const provider = await getConfiguredProvider()
// For Anthropic, use minimal max_tokens
const params = buildCompletionParams(modelId, { const params = buildCompletionParams(modelId, {
messages: [{ role: 'user', content: 'test' }], messages: [{ role: 'user', content: 'test' }],
maxTokens: 1, maxTokens: provider === 'anthropic' ? 16 : 1,
}) })
await client.chat.completions.create(params) await client.chat.completions.create(params)
@@ -407,11 +616,13 @@ export async function testOpenAIConnection(): Promise<{
}> { }> {
try { try {
const client = await getOpenAI() const client = await getOpenAI()
const provider = await getConfiguredProvider()
if (!client) { if (!client) {
const label = provider === 'anthropic' ? 'Anthropic' : 'OpenAI'
return { return {
success: false, success: false,
error: 'OpenAI API key not configured', error: `${label} API key not configured`,
} }
} }
@@ -421,7 +632,7 @@ export async function testOpenAIConnection(): Promise<{
// Test with the configured model using correct parameters // Test with the configured model using correct parameters
const params = buildCompletionParams(configuredModel, { const params = buildCompletionParams(configuredModel, {
messages: [{ role: 'user', content: 'Hello' }], messages: [{ role: 'user', content: 'Hello' }],
maxTokens: 5, maxTokens: provider === 'anthropic' ? 16 : 5,
}) })
const response = await client.chat.completions.create(params) const response = await client.chat.completions.create(params)
@@ -436,7 +647,7 @@ export async function testOpenAIConnection(): Promise<{
const configuredModel = await getConfiguredModel() const configuredModel = await getConfiguredModel()
// Check for model-specific errors // Check for model-specific errors
if (message.includes('does not exist') || message.includes('model_not_found')) { if (message.includes('does not exist') || message.includes('model_not_found') || message.includes('not_found_error')) {
return { return {
success: false, success: false,
error: `Model "${configuredModel}" is not available. Check Settings → AI to select a valid model.`, error: `Model "${configuredModel}" is not available. Check Settings → AI to select a valid model.`,

View File

@@ -51,6 +51,7 @@ import { roundEngineRouter } from './roundEngine'
import { roundAssignmentRouter } from './roundAssignment' import { roundAssignmentRouter } from './roundAssignment'
import { deliberationRouter } from './deliberation' import { deliberationRouter } from './deliberation'
import { resultLockRouter } from './resultLock' import { resultLockRouter } from './resultLock'
import { testEnvironmentRouter } from './testEnvironment'
/** /**
* Root tRPC router that combines all domain routers * Root tRPC router that combines all domain routers
@@ -108,6 +109,8 @@ export const appRouter = router({
roundAssignment: roundAssignmentRouter, roundAssignment: roundAssignmentRouter,
deliberation: deliberationRouter, deliberation: deliberationRouter,
resultLock: resultLockRouter, resultLock: resultLockRouter,
// Test environment
testEnvironment: testEnvironmentRouter,
}) })
export type AppRouter = typeof appRouter export type AppRouter = typeof appRouter

View File

@@ -12,8 +12,8 @@ const editionOrRoundInput = z.object({
}) })
function projectWhere(input: { roundId?: string; programId?: string }) { function projectWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { projectRoundStates: { some: { roundId: input.roundId } } } if (input.roundId) return { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } }
return { programId: input.programId! } return { isTest: false, programId: input.programId! }
} }
function assignmentWhere(input: { roundId?: string; programId?: string }) { function assignmentWhere(input: { roundId?: string; programId?: string }) {
@@ -263,7 +263,7 @@ export const analyticsRouter = router({
if (round?.roundType === 'EVALUATION') { if (round?.roundType === 'EVALUATION') {
// For evaluation rounds, break down by evaluation status per project // For evaluation rounds, break down by evaluation status per project
const projects = await ctx.prisma.projectRoundState.findMany({ const projects = await ctx.prisma.projectRoundState.findMany({
where: { roundId: input.roundId }, where: { roundId: input.roundId, project: { isTest: false } },
select: { select: {
projectId: true, projectId: true,
project: { project: {
@@ -309,7 +309,7 @@ export const analyticsRouter = router({
// Non-evaluation rounds: use ProjectRoundState // Non-evaluation rounds: use ProjectRoundState
const states = await ctx.prisma.projectRoundState.groupBy({ const states = await ctx.prisma.projectRoundState.groupBy({
by: ['state'], by: ['state'],
where: { roundId: input.roundId }, where: { roundId: input.roundId, project: { isTest: false } },
_count: true, _count: true,
}) })
return states.map((s) => ({ return states.map((s) => ({
@@ -469,8 +469,8 @@ export const analyticsRouter = router({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const where = input.roundId const where = input.roundId
? { assignments: { some: { roundId: input.roundId } } } ? { isTest: false, assignments: { some: { roundId: input.roundId } } }
: { programId: input.programId } : { isTest: false, programId: input.programId }
const distribution = await ctx.prisma.project.groupBy({ const distribution = await ctx.prisma.project.groupBy({
by: ['country'], by: ['country'],
@@ -537,7 +537,7 @@ export const analyticsRouter = router({
// Count distinct projects per round via assignments // Count distinct projects per round via assignments
const projectAssignments = await ctx.prisma.assignment.findMany({ const projectAssignments = await ctx.prisma.assignment.findMany({
where: { roundId: { in: roundIds } }, where: { roundId: { in: roundIds }, project: { isTest: false } },
select: { roundId: true, projectId: true }, select: { roundId: true, projectId: true },
distinct: ['roundId', 'projectId'], distinct: ['roundId', 'projectId'],
}) })
@@ -714,12 +714,14 @@ export const analyticsRouter = router({
const roundId = input?.roundId const roundId = input?.roundId
const projectFilter = roundId const projectFilter = roundId
? { projectRoundStates: { some: { roundId } } } ? { isTest: false, projectRoundStates: { some: { roundId } } }
: {} : { isTest: false }
const assignmentFilter = roundId ? { roundId } : {} const assignmentFilter = roundId
? { roundId }
: { round: { competition: { isTest: false } } }
const evalFilter = roundId const evalFilter = roundId
? { assignment: { roundId }, status: 'SUBMITTED' as const } ? { assignment: { roundId }, status: 'SUBMITTED' as const }
: { status: 'SUBMITTED' as const } : { assignment: { round: { competition: { isTest: false } } }, status: 'SUBMITTED' as const }
const [ const [
programCount, programCount,
@@ -730,9 +732,9 @@ export const analyticsRouter = router({
totalAssignments, totalAssignments,
evaluationScores, evaluationScores,
] = await Promise.all([ ] = await Promise.all([
ctx.prisma.program.count(), ctx.prisma.program.count({ where: { isTest: false } }),
ctx.prisma.round.findMany({ ctx.prisma.round.findMany({
where: { status: 'ROUND_ACTIVE' }, where: { status: 'ROUND_ACTIVE', competition: { isTest: false } },
select: { id: true, name: true }, select: { id: true, name: true },
take: 5, take: 5,
}), }),
@@ -743,7 +745,7 @@ export const analyticsRouter = router({
select: { userId: true }, select: { userId: true },
distinct: ['userId'], distinct: ['userId'],
}).then((rows) => rows.length) }).then((rows) => rows.length)
: ctx.prisma.user.count({ where: { role: 'JURY_MEMBER', status: 'ACTIVE' } }), : ctx.prisma.user.count({ where: { isTest: false, role: 'JURY_MEMBER', status: 'ACTIVE' } }),
ctx.prisma.evaluation.count({ where: evalFilter }), ctx.prisma.evaluation.count({ where: evalFilter }),
ctx.prisma.assignment.count({ where: assignmentFilter }), ctx.prisma.assignment.count({ where: assignmentFilter }),
ctx.prisma.evaluation.findMany({ ctx.prisma.evaluation.findMany({
@@ -988,7 +990,7 @@ export const analyticsRouter = router({
}) })
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {} const where: Record<string, unknown> = { isTest: false }
if (input.roundId) { if (input.roundId) {
where.projectRoundStates = { some: { roundId: input.roundId } } where.projectRoundStates = { some: { roundId: input.roundId } }
@@ -1151,15 +1153,15 @@ export const analyticsRouter = router({
switch (roundType) { switch (roundType) {
case 'INTAKE': { case 'INTAKE': {
const [total, byState, byCategory] = await Promise.all([ const [total, byState, byCategory] = await Promise.all([
ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId } }), ctx.prisma.projectRoundState.count({ where: { roundId: input.roundId, project: { isTest: false } } }),
ctx.prisma.projectRoundState.groupBy({ ctx.prisma.projectRoundState.groupBy({
by: ['state'], by: ['state'],
where: { roundId: input.roundId }, where: { roundId: input.roundId, project: { isTest: false } },
_count: true, _count: true,
}), }),
ctx.prisma.project.groupBy({ ctx.prisma.project.groupBy({
by: ['competitionCategory'], by: ['competitionCategory'],
where: { projectRoundStates: { some: { roundId: input.roundId } } }, where: { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } },
_count: true, _count: true,
}), }),
]) ])
@@ -1395,7 +1397,7 @@ export const analyticsRouter = router({
// Get competition rounds for file grouping // Get competition rounds for file grouping
let competitionRounds: { id: string; name: string; roundType: string }[] = [] let competitionRounds: { id: string; name: string; roundType: string }[] = []
const competition = await ctx.prisma.competition.findFirst({ const competition = await ctx.prisma.competition.findFirst({
where: { programId: projectRaw.programId }, where: { programId: projectRaw.programId, isTest: false },
include: { rounds: { select: { id: true, name: true, roundType: true }, orderBy: { sortOrder: 'asc' } } }, include: { rounds: { select: { id: true, name: true, roundType: true }, orderBy: { sortOrder: 'asc' } } },
}) })
if (competition) { if (competition) {
@@ -1478,9 +1480,23 @@ export const analyticsRouter = router({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const limit = input?.limit ?? 10 const limit = input?.limit ?? 10
// Exclude actions performed by test users
const testUserIds = await ctx.prisma.user.findMany({
where: { isTest: true },
select: { id: true },
}).then((users) => users.map((u) => u.id))
const entries = await ctx.prisma.decisionAuditLog.findMany({ const entries = await ctx.prisma.decisionAuditLog.findMany({
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: limit, take: limit,
...(testUserIds.length > 0 && {
where: {
OR: [
{ actorId: null },
{ actorId: { notIn: testUserIds } },
],
},
}),
select: { select: {
id: true, id: true,
eventType: true, eventType: true,
@@ -1496,7 +1512,7 @@ export const analyticsRouter = router({
const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[] const actorIds = [...new Set(entries.map((e) => e.actorId).filter(Boolean))] as string[]
const actors = actorIds.length > 0 const actors = actorIds.length > 0
? await ctx.prisma.user.findMany({ ? await ctx.prisma.user.findMany({
where: { id: { in: actorIds } }, where: { id: { in: actorIds }, isTest: false },
select: { id: true, name: true }, select: { id: true, name: true },
}) })
: [] : []

View File

@@ -105,7 +105,7 @@ export const applicationRouter = router({
if (input.mode === 'edition') { if (input.mode === 'edition') {
// Edition-wide application mode // Edition-wide application mode
const program = await ctx.prisma.program.findFirst({ const program = await ctx.prisma.program.findFirst({
where: { slug: input.slug }, where: { slug: input.slug, isTest: false },
}) })
if (!program) { if (!program) {
@@ -687,6 +687,7 @@ export const applicationRouter = router({
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { where: {
isDraft: true, isDraft: true,
isTest: false,
}, },
}) })
@@ -837,6 +838,7 @@ export const applicationRouter = router({
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { where: {
isDraft: true, isDraft: true,
isTest: false,
}, },
}) })

View File

@@ -560,6 +560,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
where: { where: {
role: 'JURY_MEMBER', role: 'JURY_MEMBER',
status: 'ACTIVE', status: 'ACTIVE',
isTest: false,
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}), ...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
}, },
select: { select: {
@@ -1255,6 +1256,7 @@ export const assignmentRouter = router({
where: { where: {
role: 'JURY_MEMBER', role: 'JURY_MEMBER',
status: 'ACTIVE', status: 'ACTIVE',
isTest: false,
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}), ...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
}, },
select: { select: {

View File

@@ -142,7 +142,7 @@ export const competitionRouter = router({
.input(z.object({ programId: z.string() })) .input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return ctx.prisma.competition.findMany({ return ctx.prisma.competition.findMany({
where: { programId: input.programId }, where: { programId: input.programId, isTest: false },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { include: {
_count: { _count: {
@@ -254,7 +254,7 @@ export const competitionRouter = router({
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))] const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
if (competitionIds.length === 0) return [] if (competitionIds.length === 0) return []
return ctx.prisma.competition.findMany({ return ctx.prisma.competition.findMany({
where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' } }, where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' }, isTest: false },
include: { include: {
rounds: { rounds: {
orderBy: { sortOrder: 'asc' }, orderBy: { sortOrder: 'asc' },

View File

@@ -172,18 +172,19 @@ export const dashboardRouter = router({
// 7. Project count // 7. Project count
ctx.prisma.project.count({ ctx.prisma.project.count({
where: { programId: editionId }, where: { programId: editionId, isTest: false },
}), }),
// 8. New projects this week // 8. New projects this week
ctx.prisma.project.count({ ctx.prisma.project.count({
where: { programId: editionId, createdAt: { gte: sevenDaysAgo } }, where: { programId: editionId, isTest: false, createdAt: { gte: sevenDaysAgo } },
}), }),
// 9. Total jurors // 9. Total jurors
ctx.prisma.user.count({ ctx.prisma.user.count({
where: { where: {
role: 'JURY_MEMBER', role: 'JURY_MEMBER',
isTest: false,
status: { in: ['ACTIVE', 'INVITED', 'NONE'] }, status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
assignments: { some: { round: { competition: { programId: editionId } } } }, assignments: { some: { round: { competition: { programId: editionId } } } },
}, },
@@ -193,6 +194,7 @@ export const dashboardRouter = router({
ctx.prisma.user.count({ ctx.prisma.user.count({
where: { where: {
role: 'JURY_MEMBER', role: 'JURY_MEMBER',
isTest: false,
status: 'ACTIVE', status: 'ACTIVE',
assignments: { some: { round: { competition: { programId: editionId } } } }, assignments: { some: { round: { competition: { programId: editionId } } } },
}, },
@@ -212,7 +214,7 @@ export const dashboardRouter = router({
// 13. Latest projects // 13. Latest projects
ctx.prisma.project.findMany({ ctx.prisma.project.findMany({
where: { programId: editionId }, where: { programId: editionId, isTest: false },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 8, take: 8,
select: { select: {
@@ -232,20 +234,20 @@ export const dashboardRouter = router({
// 14. Category breakdown // 14. Category breakdown
ctx.prisma.project.groupBy({ ctx.prisma.project.groupBy({
by: ['competitionCategory'], by: ['competitionCategory'],
where: { programId: editionId }, where: { programId: editionId, isTest: false },
_count: true, _count: true,
}), }),
// 15. Ocean issue breakdown // 15. Ocean issue breakdown
ctx.prisma.project.groupBy({ ctx.prisma.project.groupBy({
by: ['oceanIssue'], by: ['oceanIssue'],
where: { programId: editionId }, where: { programId: editionId, isTest: false },
_count: true, _count: true,
}), }),
// 16. Recent activity // 16. Recent activity (exclude test user actions)
ctx.prisma.auditLog.findMany({ ctx.prisma.auditLog.findMany({
where: { timestamp: { gte: sevenDaysAgo } }, where: { timestamp: { gte: sevenDaysAgo }, user: { isTest: false } },
orderBy: { timestamp: 'desc' }, orderBy: { timestamp: 'desc' },
take: 8, take: 8,
select: { select: {

View File

@@ -105,6 +105,7 @@ export const exportRouter = router({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { where: {
isTest: false,
assignments: { some: { roundId: input.roundId } }, assignments: { some: { roundId: input.roundId } },
}, },
include: { include: {
@@ -355,7 +356,7 @@ export const exportRouter = router({
} }
const logs = await ctx.prisma.auditLog.findMany({ const logs = await ctx.prisma.auditLog.findMany({
where, where: { ...where, user: { isTest: false } },
orderBy: { timestamp: 'desc' }, orderBy: { timestamp: 'desc' },
include: { include: {
user: { select: { name: true, email: true } }, user: { select: { name: true, email: true } },
@@ -431,7 +432,7 @@ export const exportRouter = router({
if (includeSection('summary')) { if (includeSection('summary')) {
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([ const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
ctx.prisma.project.count({ ctx.prisma.project.count({
where: { assignments: { some: { roundId: input.roundId } } }, where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
}), }),
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }), ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.evaluation.count({ ctx.prisma.evaluation.count({
@@ -486,7 +487,7 @@ export const exportRouter = router({
// Rankings // Rankings
if (includeSection('rankings')) { if (includeSection('rankings')) {
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { assignments: { some: { roundId: input.roundId } } }, where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
select: { select: {
id: true, id: true,
title: true, title: true,

View File

@@ -994,6 +994,7 @@ export const fileRouter = router({
// Build project filter // Build project filter
const projectWhere: Record<string, unknown> = { const projectWhere: Record<string, unknown> = {
programId: window.competition.programId, programId: window.competition.programId,
isTest: false,
} }
if (input.search) { if (input.search) {
projectWhere.OR = [ projectWhere.OR = [
@@ -1303,6 +1304,7 @@ export const fileRouter = router({
// Build project filter // Build project filter
const projectWhere: Record<string, unknown> = { const projectWhere: Record<string, unknown> = {
programId: round.competition.programId, programId: round.competition.programId,
isTest: false,
} }
if (input.search) { if (input.search) {
projectWhere.OR = [ projectWhere.OR = [

View File

@@ -115,6 +115,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
roundId, roundId,
exitedAt: null, exitedAt: null,
state: { in: ['PENDING', 'IN_PROGRESS'] }, state: { in: ['PENDING', 'IN_PROGRESS'] },
project: { isTest: false },
}, },
include: { include: {
project: { project: {

View File

@@ -420,6 +420,7 @@ export const mentorRouter = router({
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { where: {
programId: input.programId, programId: input.programId,
isTest: false,
mentorAssignment: null, mentorAssignment: null,
wantsMentorship: true, wantsMentorship: true,
}, },

View File

@@ -402,7 +402,7 @@ async function resolveRecipients(
const role = filter?.role as string const role = filter?.role as string
if (!role) return [] if (!role) return []
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { role: role as any, status: 'ACTIVE' }, where: { role: role as any, status: 'ACTIVE', isTest: false },
select: { id: true }, select: { id: true },
}) })
return users.map((u) => u.id) return users.map((u) => u.id)
@@ -412,7 +412,7 @@ async function resolveRecipients(
const targetRoundId = roundId || (filter?.roundId as string) const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return [] if (!targetRoundId) return []
const assignments = await prisma.assignment.findMany({ const assignments = await prisma.assignment.findMany({
where: { roundId: targetRoundId }, where: { roundId: targetRoundId, user: { isTest: false } },
select: { userId: true }, select: { userId: true },
distinct: ['userId'], distinct: ['userId'],
}) })
@@ -423,7 +423,7 @@ async function resolveRecipients(
const programId = filter?.programId as string const programId = filter?.programId as string
if (!programId) return [] if (!programId) return []
const projects = await prisma.project.findMany({ const projects = await prisma.project.findMany({
where: { programId }, where: { programId, isTest: false },
select: { submittedByUserId: true }, select: { submittedByUserId: true },
}) })
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[]) const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
@@ -432,7 +432,7 @@ async function resolveRecipients(
case 'ALL': { case 'ALL': {
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { status: 'ACTIVE' }, where: { status: 'ACTIVE', isTest: false },
select: { id: true }, select: { id: true },
}) })
return users.map((u) => u.id) return users.map((u) => u.id)

View File

@@ -22,7 +22,7 @@ export const programRouter = router({
const includeStages = input?.includeStages || false const includeStages = input?.includeStages || false
const programs = await ctx.prisma.program.findMany({ const programs = await ctx.prisma.program.findMany({
where: input?.status ? { status: input.status } : undefined, where: input?.status ? { isTest: false, status: input.status } : { isTest: false },
orderBy: { year: 'desc' }, orderBy: { year: 'desc' },
include: includeStages include: includeStages
? { ? {

View File

@@ -103,6 +103,7 @@ export const projectPoolRouter = router({
// Build where clause // Build where clause
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
isTest: false,
programId, programId,
} }
@@ -317,6 +318,7 @@ export const projectPoolRouter = router({
// Find projects to assign // Find projects to assign
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
isTest: false,
programId, programId,
} }

View File

@@ -84,7 +84,9 @@ export const projectRouter = router({
const skip = (page - 1) * perPage const skip = (page - 1) * perPage
// Build where clause // Build where clause
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {
isTest: false,
}
// Filter by program // Filter by program
if (programId) where.programId = programId if (programId) where.programId = programId
@@ -219,7 +221,9 @@ export const projectRouter = router({
wantsMentorship, hasFiles, hasAssignments, wantsMentorship, hasFiles, hasAssignments,
} = input } = input
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {
isTest: false,
}
if (programId) where.programId = programId if (programId) where.programId = programId
if (roundId) { if (roundId) {
@@ -357,19 +361,19 @@ export const projectRouter = router({
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
const [countries, categories, issues] = await Promise.all([ const [countries, categories, issues] = await Promise.all([
ctx.prisma.project.findMany({ ctx.prisma.project.findMany({
where: { country: { not: null } }, where: { isTest: false, country: { not: null } },
select: { country: true }, select: { country: true },
distinct: ['country'], distinct: ['country'],
orderBy: { country: 'asc' }, orderBy: { country: 'asc' },
}), }),
ctx.prisma.project.groupBy({ ctx.prisma.project.groupBy({
by: ['competitionCategory'], by: ['competitionCategory'],
where: { competitionCategory: { not: null } }, where: { isTest: false, competitionCategory: { not: null } },
_count: true, _count: true,
}), }),
ctx.prisma.project.groupBy({ ctx.prisma.project.groupBy({
by: ['oceanIssue'], by: ['oceanIssue'],
where: { oceanIssue: { not: null } }, where: { isTest: false, oceanIssue: { not: null } },
_count: true, _count: true,
}), }),
]) ])
@@ -838,7 +842,7 @@ export const projectRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.ids } }, where: { id: { in: input.ids }, isTest: false },
select: { id: true, title: true, status: true }, select: { id: true, title: true, status: true },
}) })
@@ -948,11 +952,13 @@ export const projectRouter = router({
programId: z.string().optional(), programId: z.string().optional(),
})) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {
isTest: false,
}
if (input.programId) where.programId = input.programId if (input.programId) where.programId = input.programId
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: Object.keys(where).length > 0 ? where : undefined, where,
select: { tags: true }, select: { tags: true },
}) })
@@ -984,6 +990,7 @@ export const projectRouter = router({
const projects = await ctx.prisma.project.findMany({ const projects = await ctx.prisma.project.findMany({
where: { where: {
id: { in: input.ids }, id: { in: input.ids },
isTest: false,
}, },
select: { id: true, title: true }, select: { id: true, title: true },
}) })
@@ -1102,6 +1109,7 @@ export const projectRouter = router({
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
programId, programId,
isTest: false,
projectRoundStates: { none: {} }, // Projects not assigned to any round projectRoundStates: { none: {} }, // Projects not assigned to any round
} }

View File

@@ -17,6 +17,11 @@ function categorizeModel(modelId: string): string {
if (id.startsWith('gpt-4')) return 'gpt-4' if (id.startsWith('gpt-4')) return 'gpt-4'
if (id.startsWith('gpt-3.5')) return 'gpt-3.5' if (id.startsWith('gpt-3.5')) return 'gpt-3.5'
if (id.startsWith('o1') || id.startsWith('o3') || id.startsWith('o4')) return 'reasoning' if (id.startsWith('o1') || id.startsWith('o3') || id.startsWith('o4')) return 'reasoning'
// Anthropic Claude models
if (id.startsWith('claude-opus-4-5') || id.startsWith('claude-sonnet-4-5')) return 'claude-4.5'
if (id.startsWith('claude-opus-4') || id.startsWith('claude-sonnet-4')) return 'claude-4'
if (id.startsWith('claude-haiku') || id.startsWith('claude-3')) return 'claude-3.5'
if (id.startsWith('claude')) return 'claude-4'
return 'other' return 'other'
} }
@@ -26,16 +31,10 @@ export const settingsRouter = router({
* These are non-sensitive settings that can be exposed to any user * These are non-sensitive settings that can be exposed to any user
*/ */
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => { getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
const [whatsappEnabled, defaultLocale, availableLocales, juryCompareEnabled] = await Promise.all([ const [whatsappEnabled, juryCompareEnabled] = await Promise.all([
ctx.prisma.systemSettings.findUnique({ ctx.prisma.systemSettings.findUnique({
where: { key: 'whatsapp_enabled' }, where: { key: 'whatsapp_enabled' },
}), }),
ctx.prisma.systemSettings.findUnique({
where: { key: 'i18n_default_locale' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'i18n_available_locales' },
}),
ctx.prisma.systemSettings.findUnique({ ctx.prisma.systemSettings.findUnique({
where: { key: 'jury_compare_enabled' }, where: { key: 'jury_compare_enabled' },
}), }),
@@ -43,8 +42,6 @@ export const settingsRouter = router({
return { return {
whatsappEnabled: whatsappEnabled?.value === 'true', whatsappEnabled: whatsappEnabled?.value === 'true',
defaultLocale: defaultLocale?.value || 'en',
availableLocales: availableLocales?.value ? JSON.parse(availableLocales.value) : ['en', 'fr'],
juryCompareEnabled: juryCompareEnabled?.value === 'true', juryCompareEnabled: juryCompareEnabled?.value === 'true',
} }
}), }),
@@ -171,14 +168,13 @@ export const settingsRouter = router({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
// Infer category from key prefix if not provided // Infer category from key prefix if not provided
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' | 'LOCALIZATION' => { const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' => {
if (key.startsWith('openai') || key.startsWith('ai_')) return 'AI' if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI'
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL' if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE' if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING' if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
if (key.startsWith('whatsapp_')) return 'WHATSAPP' if (key.startsWith('whatsapp_')) return 'WHATSAPP'
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY' if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
if (key.startsWith('i18n_') || key.startsWith('locale_')) return 'LOCALIZATION'
return 'DEFAULTS' return 'DEFAULTS'
} }
@@ -206,7 +202,7 @@ export const settingsRouter = router({
} }
// Reset OpenAI client if API key, base URL, model, or provider changed // Reset OpenAI client if API key, base URL, model, or provider changed
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model' || s.key === 'ai_provider')) { if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'anthropic_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model' || s.key === 'ai_provider')) {
const { resetOpenAIClient } = await import('@/lib/openai') const { resetOpenAIClient } = await import('@/lib/openai')
resetOpenAIClient() resetOpenAIClient()
} }
@@ -276,9 +272,9 @@ export const settingsRouter = router({
category: categorizeModel(model), category: categorizeModel(model),
})) }))
// Sort: GPT-5+ first, then GPT-4o, then other GPT-4, then GPT-3.5, then reasoning models // Sort by category priority
const sorted = categorizedModels.sort((a, b) => { const sorted = categorizedModels.sort((a, b) => {
const order = ['gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other'] const order = ['claude-4.5', 'claude-4', 'claude-3.5', 'gpt-5+', 'gpt-4o', 'gpt-4', 'gpt-3.5', 'reasoning', 'other']
const aOrder = order.findIndex(cat => a.category === cat) const aOrder = order.findIndex(cat => a.category === cat)
const bOrder = order.findIndex(cat => b.category === cat) const bOrder = order.findIndex(cat => b.category === cat)
if (aOrder !== bOrder) return aOrder - bOrder if (aOrder !== bOrder) return aOrder - bOrder
@@ -740,62 +736,4 @@ export const settingsRouter = router({
return results return results
}), }),
/**
* Get localization settings
*/
getLocalizationSettings: adminProcedure.query(async ({ ctx }) => {
const settings = await ctx.prisma.systemSettings.findMany({
where: { category: 'LOCALIZATION' },
orderBy: { key: 'asc' },
})
return settings
}),
/**
* Update localization settings
*/
updateLocalizationSettings: superAdminProcedure
.input(
z.object({
settings: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
const results = await Promise.all(
input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({
where: { key: s.key },
update: { value: s.value, updatedBy: ctx.user.id },
create: {
key: s.key,
value: s.value,
category: 'LOCALIZATION',
updatedBy: ctx.user.id,
},
})
)
)
try {
await logAudit({
prisma: ctx.prisma,
userId: ctx.user.id,
action: 'UPDATE_LOCALIZATION_SETTINGS',
entityType: 'SystemSettings',
detailsJson: { keys: input.settings.map((s) => s.key) },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
} catch {
// Never throw on audit failure
}
return results
}),
}) })

View File

@@ -93,7 +93,7 @@ export const specialAwardRouter = router({
let { competition } = award let { competition } = award
if (!competition && award.programId) { if (!competition && award.programId) {
const comp = await ctx.prisma.competition.findFirst({ const comp = await ctx.prisma.competition.findFirst({
where: { programId: award.programId }, where: { programId: award.programId, isTest: false },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } }, select: { id: true, name: true, rounds: { select: { id: true, name: true, roundType: true, sortOrder: true }, orderBy: { sortOrder: 'asc' as const } } },
}) })
@@ -141,7 +141,7 @@ export const specialAwardRouter = router({
let competitionId = input.competitionId let competitionId = input.competitionId
if (!competitionId) { if (!competitionId) {
const comp = await ctx.prisma.competition.findFirst({ const comp = await ctx.prisma.competition.findFirst({
where: { programId: input.programId }, where: { programId: input.programId, isTest: false },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
select: { id: true }, select: { id: true },
}) })
@@ -217,7 +217,7 @@ export const specialAwardRouter = router({
}) })
if (existing && !existing.competitionId) { if (existing && !existing.competitionId) {
const comp = await ctx.prisma.competition.findFirst({ const comp = await ctx.prisma.competition.findFirst({
where: { programId: existing.programId }, where: { programId: existing.programId, isTest: false },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
select: { id: true }, select: { id: true },
}) })
@@ -404,7 +404,7 @@ export const specialAwardRouter = router({
const { awardId, eligibleOnly, page, perPage } = input const { awardId, eligibleOnly, page, perPage } = input
const skip = (page - 1) * perPage const skip = (page - 1) * perPage
const where: Record<string, unknown> = { awardId } const where: Record<string, unknown> = { awardId, project: { isTest: false } }
if (eligibleOnly) where.eligible = true if (eligibleOnly) where.eligible = true
const [eligibilities, total] = await Promise.all([ const [eligibilities, total] = await Promise.all([

View File

@@ -53,7 +53,7 @@ async function runTaggingJob(jobId: string, userId: string) {
if (!job.programId) { if (!job.programId) {
throw new Error('Job must have a programId') throw new Error('Job must have a programId')
} }
const whereClause = { programId: job.programId } const whereClause = { programId: job.programId, isTest: false }
const allProjects = await prisma.project.findMany({ const allProjects = await prisma.project.findMany({
where: whereClause, where: whereClause,
@@ -196,11 +196,13 @@ export const tagRouter = router({
const userCount = await ctx.prisma.user.count({ const userCount = await ctx.prisma.user.count({
where: { where: {
expertiseTags: { has: tag.name }, expertiseTags: { has: tag.name },
isTest: false,
}, },
}) })
const projectCount = await ctx.prisma.project.count({ const projectCount = await ctx.prisma.project.count({
where: { where: {
tags: { has: tag.name }, tags: { has: tag.name },
isTest: false,
}, },
}) })
return { return {
@@ -228,10 +230,10 @@ export const tagRouter = router({
// Get usage counts // Get usage counts
const [userCount, projectCount] = await Promise.all([ const [userCount, projectCount] = await Promise.all([
ctx.prisma.user.count({ ctx.prisma.user.count({
where: { expertiseTags: { has: tag.name } }, where: { expertiseTags: { has: tag.name }, isTest: false },
}), }),
ctx.prisma.project.count({ ctx.prisma.project.count({
where: { tags: { has: tag.name } }, where: { tags: { has: tag.name }, isTest: false },
}), }),
]) ])
@@ -354,7 +356,7 @@ export const tagRouter = router({
// Update users // Update users
const usersWithTag = await ctx.prisma.user.findMany({ const usersWithTag = await ctx.prisma.user.findMany({
where: { expertiseTags: { has: oldTag.name } }, where: { expertiseTags: { has: oldTag.name }, isTest: false },
select: { id: true, expertiseTags: true }, select: { id: true, expertiseTags: true },
}) })
@@ -371,7 +373,7 @@ export const tagRouter = router({
// Update projects // Update projects
const projectsWithTag = await ctx.prisma.project.findMany({ const projectsWithTag = await ctx.prisma.project.findMany({
where: { tags: { has: oldTag.name } }, where: { tags: { has: oldTag.name }, isTest: false },
select: { id: true, tags: true }, select: { id: true, tags: true },
}) })
@@ -412,9 +414,9 @@ export const tagRouter = router({
where: { id: input.id }, where: { id: input.id },
}) })
// Remove tag from all users // Remove tag from all users (excluding test users)
const usersWithTag = await ctx.prisma.user.findMany({ const usersWithTag = await ctx.prisma.user.findMany({
where: { expertiseTags: { has: tag.name } }, where: { expertiseTags: { has: tag.name }, isTest: false },
select: { id: true, expertiseTags: true }, select: { id: true, expertiseTags: true },
}) })
@@ -427,9 +429,9 @@ export const tagRouter = router({
}) })
} }
// Remove tag from all projects // Remove tag from all projects (excluding test projects)
const projectsWithTag = await ctx.prisma.project.findMany({ const projectsWithTag = await ctx.prisma.project.findMany({
where: { tags: { has: tag.name } }, where: { tags: { has: tag.name }, isTest: false },
select: { id: true, tags: true }, select: { id: true, tags: true },
}) })

View File

@@ -0,0 +1,92 @@
import { router, superAdminProcedure, protectedProcedure } from '../trpc'
import { TRPCError } from '@trpc/server'
import { createTestEnvironment, tearDownTestEnvironment } from '../services/test-environment'
export const testEnvironmentRouter = router({
/**
* Get the current test environment status.
* Uses a custom auth check: allows access if realRole OR role is SUPER_ADMIN.
* This enables the impersonation banner to fetch test users while impersonating.
*/
status: protectedProcedure.query(async ({ ctx }) => {
// Allow access if the user's actual role (or impersonated-from role) is SUPER_ADMIN
const effectiveRole = (ctx.session?.user as any)?.realRole || ctx.user.role
if (effectiveRole !== 'SUPER_ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Super admin access required' })
}
const competition = await ctx.prisma.competition.findFirst({
where: { isTest: true },
select: {
id: true,
name: true,
status: true,
createdAt: true,
program: {
select: { id: true, name: true },
},
rounds: {
select: { id: true, name: true, roundType: true, status: true, sortOrder: true },
orderBy: { sortOrder: 'asc' },
},
},
})
if (!competition) {
return { active: false as const }
}
// Get test users grouped by role
const testUsers = await ctx.prisma.user.findMany({
where: { isTest: true },
select: { id: true, name: true, email: true, role: true },
orderBy: [{ role: 'asc' }, { name: 'asc' }],
})
// Get email redirect setting
const emailRedirect = await ctx.prisma.systemSettings.findUnique({
where: { key: 'test_email_redirect' },
select: { value: true },
})
return {
active: true as const,
competition: {
id: competition.id,
name: competition.name,
status: competition.status,
createdAt: competition.createdAt,
programId: competition.program.id,
programName: competition.program.name,
},
rounds: competition.rounds,
users: testUsers,
emailRedirect: emailRedirect?.value || null,
}
}),
/**
* Create a test environment. Idempotent — tears down existing first.
*/
create: superAdminProcedure.mutation(async ({ ctx }) => {
const result = await createTestEnvironment(ctx.prisma, ctx.user.email || '')
return result
}),
/**
* Tear down the test environment.
*/
tearDown: superAdminProcedure.mutation(async ({ ctx }) => {
const program = await ctx.prisma.program.findFirst({
where: { isTest: true },
select: { id: true },
})
if (!program) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'No test environment found' })
}
await tearDownTestEnvironment(ctx.prisma, program.id)
return { success: true }
}),
})

View File

@@ -249,7 +249,9 @@ export const userRouter = router({
const { role, roles, status, search, page, perPage } = input const { role, roles, status, search, page, perPage } = input
const skip = (page - 1) * perPage const skip = (page - 1) * perPage
const where: Record<string, unknown> = {} const where: Record<string, unknown> = {
isTest: false,
}
if (roles && roles.length > 0) { if (roles && roles.length > 0) {
where.role = { in: roles } where.role = { in: roles }
@@ -316,6 +318,7 @@ export const userRouter = router({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
isTest: false,
status: { in: ['NONE', 'INVITED'] }, status: { in: ['NONE', 'INVITED'] },
} }
@@ -929,6 +932,7 @@ export const userRouter = router({
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
isTest: false,
role: 'JURY_MEMBER', role: 'JURY_MEMBER',
status: 'ACTIVE', status: 'ACTIVE',
} }

View File

@@ -290,6 +290,7 @@ export async function generateSummary({
select: { select: {
id: true, id: true,
title: true, title: true,
isTest: true,
}, },
}) })
@@ -297,6 +298,10 @@ export async function generateSummary({
throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' }) throw new TRPCError({ code: 'NOT_FOUND', message: 'Project not found' })
} }
if (project.isTest) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cannot generate AI summaries for test projects' })
}
// Fetch submitted evaluations for this project in this round // Fetch submitted evaluations for this project in this round
const evaluations = await prisma.evaluation.findMany({ const evaluations = await prisma.evaluation.findMany({
where: { where: {

View File

@@ -103,6 +103,7 @@ async function generateCategoryShortlist(
const projects = await prisma.project.findMany({ const projects = await prisma.project.findMany({
where: { where: {
competitionCategory: category, competitionCategory: category,
isTest: false,
assignments: { some: { roundId } }, assignments: { some: { roundId } },
}, },
include: { include: {

View File

@@ -473,6 +473,10 @@ export async function tagProject(
throw new Error(`Project not found: ${projectId}`) throw new Error(`Project not found: ${projectId}`)
} }
if ((project as any).isTest) {
throw new Error(`Cannot run AI tagging on test project: ${projectId}`)
}
// Get available tags // Get available tags
const availableTags = await getAvailableTags() const availableTags = await getAvailableTags()
if (availableTags.length === 0) { if (availableTags.length === 0) {
@@ -574,7 +578,7 @@ export async function tagProjectsBatch(
// Fetch full project data for all projects at once (single DB query) // Fetch full project data for all projects at once (single DB query)
const fullProjects = await prisma.project.findMany({ const fullProjects = await prisma.project.findMany({
where: { id: { in: projects.map((p) => p.id) } }, where: { id: { in: projects.map((p) => p.id) }, isTest: false },
include: { include: {
projectTags: true, projectTags: true,
files: { select: { fileType: true } }, files: { select: { fileType: true } },
@@ -712,6 +716,10 @@ export async function getTagSuggestions(
throw new Error(`Project not found: ${projectId}`) throw new Error(`Project not found: ${projectId}`)
} }
if ((project as any).isTest) {
throw new Error(`Cannot run AI tagging on test project: ${projectId}`)
}
// Get available tags // Get available tags
const availableTags = await getAvailableTags() const availableTags = await getAvailableTags()
if (availableTags.length === 0) { if (availableTags.length === 0) {

View File

@@ -88,6 +88,7 @@ export async function processEligibilityJob(
where: { where: {
id: { in: passedIds }, id: { in: passedIds },
programId: award.programId, programId: award.programId,
isTest: false,
}, },
select: projectSelect, select: projectSelect,
}) })
@@ -99,6 +100,7 @@ export async function processEligibilityJob(
projects = await prisma.project.findMany({ projects = await prisma.project.findMany({
where: { where: {
programId: award.programId, programId: award.programId,
isTest: false,
status: { in: [...statusFilter] }, status: { in: [...statusFilter] },
}, },
select: projectSelect, select: projectSelect,

View File

@@ -320,7 +320,7 @@ export async function analyzeAllUnanalyzed(): Promise<{
total: number total: number
}> { }> {
const files = await prisma.projectFile.findMany({ const files = await prisma.projectFile.findMany({
where: { analyzedAt: null }, where: { analyzedAt: null, project: { isTest: false } },
select: { select: {
id: true, id: true,
objectKey: true, objectKey: true,

View File

@@ -32,6 +32,7 @@ export async function processDigests(
// Find users who opted in for this digest frequency // Find users who opted in for this digest frequency
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { where: {
isTest: false,
digestFrequency: type, digestFrequency: type,
status: 'ACTIVE', status: 'ACTIVE',
}, },

View File

@@ -56,7 +56,7 @@ export async function sendManualReminders(roundId: string): Promise<ReminderResu
if (usersToNotify.length === 0) return { sent, errors } if (usersToNotify.length === 0) return { sent, errors }
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { id: { in: usersToNotify } }, where: { id: { in: usersToNotify }, isTest: false },
select: { id: true, name: true, email: true }, select: { id: true, name: true, email: true },
}) })
@@ -133,6 +133,7 @@ export async function processEvaluationReminders(roundId?: string): Promise<Remi
status: 'ROUND_ACTIVE' as const, status: 'ROUND_ACTIVE' as const,
windowCloseAt: { gt: now }, windowCloseAt: { gt: now },
windowOpenAt: { lte: now }, windowOpenAt: { lte: now },
competition: { isTest: false },
...(roundId && { id: roundId }), ...(roundId && { id: roundId }),
}, },
select: { select: {
@@ -213,7 +214,7 @@ async function sendRemindersForRound(
// Get user details and their pending counts // Get user details and their pending counts
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { id: { in: usersToNotify } }, where: { id: { in: usersToNotify }, isTest: false },
select: { id: true, name: true, email: true }, select: { id: true, name: true, email: true },
}) })

View File

@@ -297,6 +297,7 @@ export async function notifyAdmins(params: {
where: { where: {
role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] }, role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
status: 'ACTIVE', status: 'ACTIVE',
isTest: false,
}, },
select: { id: true }, select: { id: true },
}) })

View File

@@ -232,6 +232,7 @@ export async function getAIMentorSuggestionsBatch(
{ role: 'JURY_MEMBER' }, { role: 'JURY_MEMBER' },
], ],
status: 'ACTIVE', status: 'ACTIVE',
isTest: false,
}, },
select: { select: {
id: true, id: true,
@@ -458,6 +459,7 @@ export async function getRoundRobinMentor(
{ role: 'JURY_MEMBER' }, { role: 'JURY_MEMBER' },
], ],
status: 'ACTIVE', status: 'ACTIVE',
isTest: false,
id: { notIn: excludeMentorIds }, id: { notIn: excludeMentorIds },
}, },
select: { select: {

View File

@@ -40,7 +40,7 @@ export async function sendNotification(
): Promise<NotificationResult> { ): Promise<NotificationResult> {
// Get user with notification preferences // Get user with notification preferences
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId, isTest: false },
select: { select: {
id: true, id: true,
email: true, email: true,

View File

@@ -94,7 +94,7 @@ export async function previewRoundAssignment(
// Load jury group members // Load jury group members
const members = await db.juryGroupMember.findMany({ const members = await db.juryGroupMember.findMany({
where: { juryGroupId: ctx.juryGroup.id }, where: { juryGroupId: ctx.juryGroup.id, user: { isTest: false } },
include: { include: {
user: { select: { id: true, name: true, email: true, role: true, bio: true, expertiseTags: true, country: true, availabilityJson: true } }, user: { select: { id: true, name: true, email: true, role: true, bio: true, expertiseTags: true, country: true, availabilityJson: true } },
}, },

View File

@@ -16,7 +16,7 @@ export async function processScheduledRounds(): Promise<{
// Find a SUPER_ADMIN to use as the actor for audit logging // Find a SUPER_ADMIN to use as the actor for audit logging
const systemActor = await prisma.user.findFirst({ const systemActor = await prisma.user.findFirst({
where: { role: 'SUPER_ADMIN' }, where: { role: 'SUPER_ADMIN', isTest: false },
select: { id: true }, select: { id: true },
}) })
@@ -29,7 +29,7 @@ export async function processScheduledRounds(): Promise<{
where: { where: {
status: 'ROUND_DRAFT', status: 'ROUND_DRAFT',
windowOpenAt: { lte: now }, windowOpenAt: { lte: now },
competition: { status: { not: 'ARCHIVED' } }, competition: { status: { not: 'ARCHIVED' }, isTest: false },
}, },
select: { id: true, name: true }, select: { id: true, name: true },
}) })
@@ -49,6 +49,7 @@ export async function processScheduledRounds(): Promise<{
where: { where: {
status: 'ROUND_ACTIVE', status: 'ROUND_ACTIVE',
windowCloseAt: { lte: now }, windowCloseAt: { lte: now },
competition: { isTest: false },
}, },
select: { id: true, name: true }, select: { id: true, name: true },
}) })

View File

@@ -362,6 +362,7 @@ export async function getSmartSuggestions(options: {
where: { where: {
role, role,
status: 'ACTIVE', status: 'ACTIVE',
isTest: false,
}, },
select: { select: {
id: true, id: true,
@@ -683,6 +684,7 @@ export async function getMentorSuggestionsForProject(
where: { where: {
role: 'MENTOR', role: 'MENTOR',
status: 'ACTIVE', status: 'ACTIVE',
isTest: false,
}, },
select: { select: {
id: true, id: true,

View File

@@ -0,0 +1,676 @@
import type { PrismaClient } from '@prisma/client'
// ─── Test Data Constants ─────────────────────────────────────────────────────
const TEST_JURY = [
{ name: 'Sophie Laurent', email: 'sophie.laurent@test.local', country: 'France', expertiseTags: ['Marine Biology', 'Conservation'] },
{ name: 'Marco Bianchi', email: 'marco.bianchi@test.local', country: 'Italy', expertiseTags: ['Sustainable Fishing', 'Policy'] },
{ name: 'Elena Petrova', email: 'elena.petrova@test.local', country: 'Germany', expertiseTags: ['Ocean Technology', 'Engineering'] },
{ name: 'James Chen', email: 'james.chen@test.local', country: 'Singapore', expertiseTags: ['Blue Economy', 'Innovation'] },
{ name: 'Aisha Diallo', email: 'aisha.diallo@test.local', country: 'Senegal', expertiseTags: ['Community Development', 'Education'] },
{ name: 'Carlos Rivera', email: 'carlos.rivera@test.local', country: 'Spain', expertiseTags: ['Climate Science', 'Renewable Energy'] },
]
const TEST_APPLICANTS_OWNERS = [
{ name: 'Léa Moreau', email: 'lea.moreau@test.local', country: 'France' },
{ name: 'Henrik Johansson', email: 'henrik.johansson@test.local', country: 'Sweden' },
{ name: 'Fatima Al-Rashid', email: 'fatima.alrashid@test.local', country: 'UAE' },
{ name: 'Yuki Tanaka', email: 'yuki.tanaka@test.local', country: 'Japan' },
{ name: 'Priya Sharma', email: 'priya.sharma@test.local', country: 'India' },
{ name: 'Lucas Oliveira', email: 'lucas.oliveira@test.local', country: 'Brazil' },
{ name: 'Nadia Kowalski', email: 'nadia.kowalski@test.local', country: 'Poland' },
{ name: 'Samuel Okonkwo', email: 'samuel.okonkwo@test.local', country: 'Nigeria' },
{ name: 'Ingrid Hansen', email: 'ingrid.hansen@test.local', country: 'Norway' },
{ name: 'Diego Fernández', email: 'diego.fernandez@test.local', country: 'Mexico' },
{ name: 'Amira Benali', email: 'amira.benali@test.local', country: 'Morocco' },
{ name: 'Thomas Müller', email: 'thomas.muller@test.local', country: 'Germany' },
]
const TEST_APPLICANTS_TEAMMATES = [
{ name: 'Marie Dubois', email: 'marie.dubois@test.local', country: 'France' },
{ name: 'Kenji Watanabe', email: 'kenji.watanabe@test.local', country: 'Japan' },
{ name: 'Ana Costa', email: 'ana.costa@test.local', country: 'Brazil' },
{ name: 'Erik Lindqvist', email: 'erik.lindqvist@test.local', country: 'Sweden' },
]
const TEST_OTHER_ROLES = [
{ name: 'Dr. Catherine Blanc', email: 'catherine.blanc@test.local', role: 'MENTOR' as const, country: 'Monaco' },
{ name: 'Oliver Schmidt', email: 'oliver.schmidt@test.local', role: 'OBSERVER' as const, country: 'Switzerland' },
{ name: 'Isabella Romano', email: 'isabella.romano@test.local', role: 'AWARD_MASTER' as const, country: 'Italy' },
{ name: 'Philippe Durand', email: 'philippe.durand@test.local', role: 'PROGRAM_ADMIN' as const, country: 'Monaco' },
]
const TEST_PROJECTS = [
{
title: 'OceanGuard: AI-Powered Marine Debris Detection',
teamName: 'OceanGuard Technologies',
description: 'Using satellite imagery and deep learning to identify and track marine debris across the Mediterranean. Our proprietary algorithm achieves 94% accuracy in detecting microplastic concentration zones, enabling targeted cleanup operations.',
country: 'France',
category: 'STARTUP' as const,
oceanIssue: 'POLLUTION_REDUCTION' as const,
},
{
title: 'Blue Carbon Ventures: Seagrass Restoration at Scale',
teamName: 'Blue Carbon Ventures',
description: 'Developing cost-effective seagrass meadow restoration techniques for Mediterranean coastal zones. Our approach combines drone-based seed dispersal with AI monitoring to restore 500+ hectares of seagrass by 2030.',
country: 'Italy',
category: 'STARTUP' as const,
oceanIssue: 'BLUE_CARBON' as const,
},
{
title: 'Reef Resilience: Coral Thermal Adaptation Program',
teamName: 'Reef Resilience Lab',
description: 'Selective breeding of thermally tolerant coral genotypes combined with biofilm-enhanced substrate technology to accelerate reef recovery in warming waters. Currently operating pilot programs in 3 Mediterranean sites.',
country: 'Monaco',
category: 'STARTUP' as const,
oceanIssue: 'HABITAT_RESTORATION' as const,
},
{
title: 'WaveHarvest: Ocean Energy for Coastal Communities',
teamName: 'WaveHarvest Energy',
description: 'Modular wave energy converters designed for small island nations and remote coastal communities. Our patented oscillating water column system delivers reliable 50kW power at 1/3 the cost of competing technologies.',
country: 'Norway',
category: 'STARTUP' as const,
oceanIssue: 'CLIMATE_MITIGATION' as const,
},
{
title: 'SailCargo: Zero-Emission Maritime Logistics',
teamName: 'SailCargo Collective',
description: 'Reviving wind-powered shipping for short-sea routes in the Mediterranean using modernized sailing vessel designs with solar-electric auxiliary propulsion. Connecting ports along the French and Italian Riviera.',
country: 'Spain',
category: 'STARTUP' as const,
oceanIssue: 'SUSTAINABLE_SHIPPING' as const,
},
{
title: 'Neptune Analytics: Smart Fisheries Management',
teamName: 'Neptune Analytics',
description: 'IoT sensor network and machine learning platform for real-time fisheries monitoring. Tracks fish stock health, migration patterns, and fishing vessel compliance to support science-based quota management.',
country: 'Portugal',
category: 'STARTUP' as const,
oceanIssue: 'SUSTAINABLE_FISHING' as const,
},
{
title: 'AquaLens: Underwater Environmental Monitoring',
teamName: 'AquaLens Research',
description: 'A network of autonomous underwater drones equipped with hyperspectral cameras for continuous marine ecosystem monitoring. Real-time data on water quality, biodiversity indices, and pollutant levels.',
country: 'Germany',
category: 'BUSINESS_CONCEPT' as const,
oceanIssue: 'TECHNOLOGY_INNOVATION' as const,
},
{
title: 'TidalConnect: Ocean Literacy Education Platform',
teamName: 'TidalConnect Foundation',
description: 'Interactive mobile platform bringing ocean science education to underserved coastal communities in West Africa. Features gamified learning modules, local language support, and citizen science data collection.',
country: 'Senegal',
category: 'BUSINESS_CONCEPT' as const,
oceanIssue: 'COMMUNITY_CAPACITY' as const,
},
{
title: 'BioFouling Solutions: Eco-Friendly Marine Coatings',
teamName: 'BioFouling Solutions',
description: 'Biomimetic antifouling coatings inspired by shark skin microstructure. Our non-toxic coating reduces fuel consumption by 12% while eliminating the release of harmful biocides into marine environments.',
country: 'Sweden',
category: 'BUSINESS_CONCEPT' as const,
oceanIssue: 'POLLUTION_REDUCTION' as const,
},
{
title: 'Kelp Climate: Industrial-Scale Kelp Farming',
teamName: 'Kelp Climate Co.',
description: 'Offshore macroalgae cultivation combining carbon sequestration with sustainable biomaterials production. Our proprietary deep-water cultivation system supports 10x faster growth rates than nearshore farms.',
country: 'Japan',
category: 'BUSINESS_CONCEPT' as const,
oceanIssue: 'BLUE_CARBON' as const,
},
{
title: 'MarineTrack: Vessel Emission Compliance System',
teamName: 'MarineTrack Systems',
description: 'Real-time satellite and AIS-based monitoring platform for enforcing IMO 2030 emission standards. Automatically detects scrubber discharge violations and sulfur emission exceedances across Mediterranean shipping lanes.',
country: 'Greece',
category: 'STARTUP' as const,
oceanIssue: 'SUSTAINABLE_SHIPPING' as const,
},
{
title: 'Mangrove Guardians: Community-Led Restoration',
teamName: 'Mangrove Guardians Network',
description: 'Empowering coastal fishing communities in Southeast Asia to restore and manage mangrove ecosystems through microfinance-linked conservation incentives and satellite-verified carbon credits.',
country: 'India',
category: 'BUSINESS_CONCEPT' as const,
oceanIssue: 'HABITAT_RESTORATION' as const,
},
]
const ROUND_DEFINITIONS = [
{ name: 'Intake', slug: 'intake', roundType: 'INTAKE' as const, sortOrder: 0 },
{ name: 'Eligibility Screening', slug: 'filtering', roundType: 'FILTERING' as const, sortOrder: 1 },
{ name: 'Expert Evaluation', slug: 'evaluation', roundType: 'EVALUATION' as const, sortOrder: 2 },
{ name: 'Document Submission', slug: 'submission', roundType: 'SUBMISSION' as const, sortOrder: 3 },
{ name: 'Mentoring Phase', slug: 'mentoring', roundType: 'MENTORING' as const, sortOrder: 4 },
{ name: 'Live Finals', slug: 'live-final', roundType: 'LIVE_FINAL' as const, sortOrder: 5 },
{ name: 'Final Deliberation', slug: 'deliberation', roundType: 'DELIBERATION' as const, sortOrder: 6 },
]
// ─── Public API ──────────────────────────────────────────────────────────────
export interface TestEnvironmentResult {
programId: string
competitionId: string
users: Array<{ id: string; name: string; email: string; role: string }>
projectCount: number
roundCount: number
}
/**
* Create a complete test environment with realistic data.
* Idempotent — tears down existing test env first.
*/
export async function createTestEnvironment(
prisma: PrismaClient,
adminEmail: string
): Promise<TestEnvironmentResult> {
// Tear down existing if any
const existing = await prisma.competition.findFirst({ where: { isTest: true } })
if (existing) {
const existingProgram = await prisma.program.findFirst({ where: { isTest: true } })
if (existingProgram) {
await tearDownTestEnvironment(prisma, existingProgram.id)
}
}
// 1. Email redirect setting
await prisma.systemSettings.upsert({
where: { key: 'test_email_redirect' },
update: { value: adminEmail },
create: { key: 'test_email_redirect', value: adminEmail, category: 'DEFAULTS' },
})
// 2. Program
const program = await prisma.program.create({
data: {
name: '[TEST] Test Environment 2026',
year: 2026,
status: 'ACTIVE',
description: 'Test environment for role impersonation and feature testing',
isTest: true,
},
})
// 3. Create test users
const createdUsers: Array<{ id: string; name: string; email: string; role: string }> = []
// Jury members
const juryUsers = await Promise.all(
TEST_JURY.map((j) =>
prisma.user.create({
data: {
name: j.name,
email: j.email,
role: 'JURY_MEMBER',
status: 'ACTIVE',
country: j.country,
expertiseTags: j.expertiseTags,
isTest: true,
mustSetPassword: false,
},
})
)
)
juryUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
// Applicant owners
const ownerUsers = await Promise.all(
TEST_APPLICANTS_OWNERS.map((a) =>
prisma.user.create({
data: {
name: a.name,
email: a.email,
role: 'APPLICANT',
status: 'ACTIVE',
country: a.country,
isTest: true,
mustSetPassword: false,
},
})
)
)
ownerUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
// Applicant teammates
const teammateUsers = await Promise.all(
TEST_APPLICANTS_TEAMMATES.map((a) =>
prisma.user.create({
data: {
name: a.name,
email: a.email,
role: 'APPLICANT',
status: 'ACTIVE',
country: a.country,
isTest: true,
mustSetPassword: false,
},
})
)
)
teammateUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
// Other roles
const otherUsers = await Promise.all(
TEST_OTHER_ROLES.map((r) =>
prisma.user.create({
data: {
name: r.name,
email: r.email,
role: r.role,
status: 'ACTIVE',
country: r.country,
isTest: true,
mustSetPassword: false,
},
})
)
)
otherUsers.forEach((u) => createdUsers.push({ id: u.id, name: u.name!, email: u.email, role: u.role }))
// 4. Create projects (one per owner)
const projects = await Promise.all(
TEST_PROJECTS.map((p, i) =>
prisma.project.create({
data: {
programId: program.id,
title: p.title,
teamName: p.teamName,
description: p.description,
country: p.country,
competitionCategory: p.category,
oceanIssue: p.oceanIssue,
status: 'SUBMITTED',
submissionSource: 'MANUAL',
submittedAt: new Date(),
submittedByUserId: ownerUsers[i].id,
isTest: true,
},
})
)
)
// 5. Add teammates to projects 0, 1, and 3
const teammateAssignments = [
{ projectIdx: 0, teammateIdx: 0 },
{ projectIdx: 0, teammateIdx: 1 },
{ projectIdx: 1, teammateIdx: 2 },
{ projectIdx: 3, teammateIdx: 3 },
]
await Promise.all(
teammateAssignments.map((ta) =>
prisma.teamMember.create({
data: {
projectId: projects[ta.projectIdx].id,
userId: teammateUsers[ta.teammateIdx].id,
role: 'MEMBER',
},
})
)
)
// Also add owners as team members (OWNER role)
await Promise.all(
projects.map((proj, i) =>
prisma.teamMember.create({
data: {
projectId: proj.id,
userId: ownerUsers[i].id,
role: 'LEAD',
},
})
)
)
// 6. Competition
const competition = await prisma.competition.create({
data: {
programId: program.id,
name: 'Test Competition 2026',
slug: `test-env-${Date.now()}`,
status: 'ACTIVE',
isTest: true,
},
})
// 7. Jury Group
const observerUser = otherUsers.find((u) => u.role === 'OBSERVER')
const juryGroup = await prisma.juryGroup.create({
data: {
competitionId: competition.id,
name: 'Test Jury Panel',
slug: 'test-jury-panel',
},
})
// Add jury members to group
await Promise.all(
juryUsers.map((ju) =>
prisma.juryGroupMember.create({
data: {
juryGroupId: juryGroup.id,
userId: ju.id,
role: 'MEMBER',
},
})
)
)
// Add observer to jury group
if (observerUser) {
await prisma.juryGroupMember.create({
data: {
juryGroupId: juryGroup.id,
userId: observerUser.id,
role: 'OBSERVER',
},
})
}
// 8. Rounds (INTAKE=ACTIVE, rest=DRAFT)
const rounds = await Promise.all(
ROUND_DEFINITIONS.map((rd) =>
prisma.round.create({
data: {
competitionId: competition.id,
name: rd.name,
slug: rd.slug,
roundType: rd.roundType,
status: rd.sortOrder === 0 ? 'ROUND_ACTIVE' : 'ROUND_DRAFT',
sortOrder: rd.sortOrder,
juryGroupId: rd.roundType === 'EVALUATION' ? juryGroup.id : undefined,
windowOpenAt: rd.sortOrder === 0 ? new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) : undefined,
windowCloseAt: rd.sortOrder === 0 ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) : undefined,
},
})
)
)
const intakeRound = rounds[0]
const evaluationRound = rounds[2]
// 9. Evaluation form on the evaluation round
const evalForm = await prisma.evaluationForm.create({
data: {
roundId: evaluationRound.id,
version: 1,
isActive: true,
criteriaJson: [
{ id: 'innovation', label: 'Innovation & Originality', description: 'Novelty of approach and potential impact', scale: '1-10', weight: 30, required: true },
{ id: 'feasibility', label: 'Technical Feasibility', description: 'Viability of implementation and scalability', scale: '1-10', weight: 40, required: true },
{ id: 'ocean_impact', label: 'Ocean Impact Potential', description: 'Direct benefit to marine ecosystems', scale: '1-10', weight: 30, required: true },
],
scalesJson: { '1-10': { min: 1, max: 10, labels: { 1: 'Poor', 5: 'Average', 10: 'Excellent' } } },
},
})
// 10. ProjectRoundState for all projects in INTAKE round (PENDING)
await Promise.all(
projects.map((proj) =>
prisma.projectRoundState.create({
data: {
projectId: proj.id,
roundId: intakeRound.id,
state: 'PENDING',
},
})
)
)
// 11. Assignments — 5 projects per jury member in evaluation round (round-robin)
const assignmentsPerJury = 5
const assignmentData: Array<{ userId: string; projectId: string; roundId: string; juryGroupId: string }> = []
for (let j = 0; j < juryUsers.length; j++) {
for (let a = 0; a < assignmentsPerJury; a++) {
const projectIdx = (j * 2 + a) % projects.length
const key = `${juryUsers[j].id}-${projects[projectIdx].id}`
// Avoid duplicate assignments
if (!assignmentData.some((ad) => ad.userId === juryUsers[j].id && ad.projectId === projects[projectIdx].id)) {
assignmentData.push({
userId: juryUsers[j].id,
projectId: projects[projectIdx].id,
roundId: evaluationRound.id,
juryGroupId: juryGroup.id,
})
}
}
}
const assignments = await Promise.all(
assignmentData.map((ad) =>
prisma.assignment.create({
data: {
userId: ad.userId,
projectId: ad.projectId,
roundId: ad.roundId,
juryGroupId: ad.juryGroupId,
method: 'MANUAL',
isCompleted: false,
},
})
)
)
// 12. Partial evaluations — first 2 jury members score their first 3-4 assignments
const evalJurors = juryUsers.slice(0, 2)
for (const juror of evalJurors) {
const jurorAssignments = assignments.filter((a) => a.userId === juror.id).slice(0, 3 + Math.round(Math.random()))
for (const assignment of jurorAssignments) {
const innovationScore = 5 + Math.floor(Math.random() * 5) // 5-9
const feasibilityScore = 4 + Math.floor(Math.random() * 5) // 4-8
const impactScore = 5 + Math.floor(Math.random() * 4) // 5-8
const globalScore = Math.round((innovationScore * 0.3 + feasibilityScore * 0.4 + impactScore * 0.3))
await prisma.evaluation.create({
data: {
assignmentId: assignment.id,
formId: evalForm.id,
status: 'SUBMITTED',
criterionScoresJson: {
innovation: innovationScore,
feasibility: feasibilityScore,
ocean_impact: impactScore,
},
globalScore,
binaryDecision: globalScore >= 6,
feedbackText: `Strong project with notable ${innovationScore >= 7 ? 'innovation' : 'potential'}. ${feasibilityScore >= 7 ? 'Highly feasible approach.' : 'Implementation timeline needs refinement.'}`,
submittedAt: new Date(Date.now() - Math.floor(Math.random() * 3 * 24 * 60 * 60 * 1000)),
},
})
// Mark assignment as completed
await prisma.assignment.update({
where: { id: assignment.id },
data: { isCompleted: true },
})
}
}
// 13. Advancement rules linking consecutive rounds
for (let i = 0; i < rounds.length - 1; i++) {
await prisma.advancementRule.create({
data: {
roundId: rounds[i].id,
ruleType: 'ADMIN_SELECTION',
configJson: {},
},
})
}
return {
programId: program.id,
competitionId: competition.id,
users: createdUsers,
projectCount: projects.length,
roundCount: rounds.length,
}
}
/**
* Tear down the test environment — delete all test data.
* Follows reverse-dependency order to avoid FK constraint errors.
*/
export async function tearDownTestEnvironment(
prisma: PrismaClient,
programId: string
): Promise<void> {
// Verify this is actually a test program
const program = await prisma.program.findUnique({
where: { id: programId },
select: { isTest: true },
})
if (!program?.isTest) {
throw new Error('Cannot tear down a non-test program')
}
// Get all test competition IDs
const competitions = await prisma.competition.findMany({
where: { programId, isTest: true },
select: { id: true },
})
const competitionIds = competitions.map((c) => c.id)
// Get all round IDs
const rounds = await prisma.round.findMany({
where: { competitionId: { in: competitionIds } },
select: { id: true },
})
const roundIds = rounds.map((r) => r.id)
// Get all test project IDs
const projects = await prisma.project.findMany({
where: { programId, isTest: true },
select: { id: true },
})
const projectIds = projects.map((p) => p.id)
// Get all test user IDs
const users = await prisma.user.findMany({
where: { isTest: true },
select: { id: true },
})
const userIds = users.map((u) => u.id)
// Delete in reverse-dependency order
// Deliberation data
if (roundIds.length > 0) {
await prisma.deliberationVote.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
await prisma.deliberationResult.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
await prisma.deliberationParticipant.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
await prisma.deliberationSession.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Decision/Audit
if (competitionIds.length > 0) {
await prisma.resultLock.deleteMany({ where: { competitionId: { in: competitionIds } } })
}
if (roundIds.length > 0) {
await prisma.decisionAuditLog.deleteMany({
where: { entityType: 'Round', entityId: { in: roundIds } },
})
}
// Evaluations → Assignments
if (roundIds.length > 0) {
await prisma.evaluation.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
await prisma.conflictOfInterest.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
await prisma.assignmentException.deleteMany({ where: { assignment: { roundId: { in: roundIds } } } })
await prisma.assignment.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Project round states
if (roundIds.length > 0) {
await prisma.projectRoundState.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Filtering
if (roundIds.length > 0) {
await prisma.filteringResult.deleteMany({ where: { roundId: { in: roundIds } } })
await prisma.filteringRule.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Evaluation forms & advancement rules
if (roundIds.length > 0) {
await prisma.evaluationForm.deleteMany({ where: { roundId: { in: roundIds } } })
await prisma.advancementRule.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Live voting
if (roundIds.length > 0) {
await prisma.liveVote.deleteMany({ where: { session: { roundId: { in: roundIds } } } })
await prisma.liveVotingSession.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Assignment intents and policies
if (roundIds.length > 0) {
await prisma.assignmentIntent.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Reminder logs
if (roundIds.length > 0) {
await prisma.reminderLog.deleteMany({ where: { roundId: { in: roundIds } } })
}
// Jury groups
if (competitionIds.length > 0) {
await prisma.juryGroupMember.deleteMany({ where: { juryGroup: { competitionId: { in: competitionIds } } } })
await prisma.juryGroup.deleteMany({ where: { competitionId: { in: competitionIds } } })
}
// Submission windows
if (competitionIds.length > 0) {
await prisma.roundSubmissionVisibility.deleteMany({ where: { submissionWindow: { competitionId: { in: competitionIds } } } })
await prisma.submissionWindow.deleteMany({ where: { competitionId: { in: competitionIds } } })
}
// Rounds
if (competitionIds.length > 0) {
await prisma.round.deleteMany({ where: { competitionId: { in: competitionIds } } })
}
// Project-related data
if (projectIds.length > 0) {
await prisma.evaluationSummary.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.evaluationDiscussion.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.awardEligibility.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.awardVote.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.projectTag.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.projectStatusHistory.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.mentorMessage.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.mentorAssignment.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.cohortProject.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.teamMember.deleteMany({ where: { projectId: { in: projectIds } } })
await prisma.projectFile.deleteMany({ where: { projectId: { in: projectIds } } })
}
// Special awards
if (competitionIds.length > 0) {
await prisma.specialAward.deleteMany({ where: { competitionId: { in: competitionIds } } })
}
// Competitions
await prisma.competition.deleteMany({ where: { programId, isTest: true } })
// Projects
await prisma.project.deleteMany({ where: { programId, isTest: true } })
// Audit logs from test users
if (userIds.length > 0) {
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
await prisma.notificationLog.deleteMany({ where: { userId: { in: userIds } } })
}
// Users
await prisma.user.deleteMany({ where: { isTest: true } })
// Program
await prisma.program.deleteMany({ where: { id: programId, isTest: true } })
// Clean up email redirect setting
await prisma.systemSettings.deleteMany({ where: { key: 'test_email_redirect' } })
}

View File

@@ -29,6 +29,7 @@ export interface LogAIUsageInput {
entityType?: string entityType?: string
entityId?: string entityId?: string
model: string model: string
provider?: string
promptTokens: number promptTokens: number
completionTokens: number completionTokens: number
totalTokens: number totalTokens: number
@@ -98,6 +99,13 @@ const MODEL_PRICING: Record<string, ModelPricing> = {
// o4 reasoning models (future-proofing) // o4 reasoning models (future-proofing)
'o4-mini': { input: 1.1, output: 4.4 }, 'o4-mini': { input: 1.1, output: 4.4 },
// Anthropic Claude models
'claude-opus-4-5-20250514': { input: 15.0, output: 75.0 },
'claude-sonnet-4-5-20250514': { input: 3.0, output: 15.0 },
'claude-haiku-3-5-20241022': { input: 0.8, output: 4.0 },
'claude-opus-4-20250514': { input: 15.0, output: 75.0 },
'claude-sonnet-4-20250514': { input: 3.0, output: 15.0 },
} }
// Default pricing for unknown models (conservative estimate) // Default pricing for unknown models (conservative estimate)
@@ -150,6 +158,16 @@ function getModelPricing(model: string): ModelPricing {
if (modelLower.startsWith('o4')) { if (modelLower.startsWith('o4')) {
return MODEL_PRICING['o4-mini'] || DEFAULT_PRICING return MODEL_PRICING['o4-mini'] || DEFAULT_PRICING
} }
// Anthropic Claude prefix fallbacks
if (modelLower.startsWith('claude-opus')) {
return { input: 15.0, output: 75.0 }
}
if (modelLower.startsWith('claude-sonnet')) {
return { input: 3.0, output: 15.0 }
}
if (modelLower.startsWith('claude-haiku')) {
return { input: 0.8, output: 4.0 }
}
return DEFAULT_PRICING return DEFAULT_PRICING
} }
@@ -200,6 +218,7 @@ export async function logAIUsage(input: LogAIUsageInput): Promise<void> {
entityType: input.entityType, entityType: input.entityType,
entityId: input.entityId, entityId: input.entityId,
model: input.model, model: input.model,
provider: input.provider,
promptTokens: input.promptTokens, promptTokens: input.promptTokens,
completionTokens: input.completionTokens, completionTokens: input.completionTokens,
totalTokens: input.totalTokens, totalTokens: input.totalTokens,