diff --git a/package-lock.json b/package-lock.json
index e64ab17..4eb8855 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "mopc-platform",
"version": "0.1.0",
"dependencies": {
+ "@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
@@ -119,6 +120,26 @@
"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": {
"version": "0.41.1",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.1.tgz",
@@ -9277,6 +9298,19 @@
"dev": true,
"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": {
"version": "0.4.1",
"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"
}
},
+ "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": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
diff --git a/package.json b/package.json
index f764a96..68a56d6 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"test:e2e": "playwright test"
},
"dependencies": {
+ "@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
diff --git a/prisma/migrations/20260221000000_add_test_isolation_anthropic_remove_locale/migration.sql b/prisma/migrations/20260221000000_add_test_isolation_anthropic_remove_locale/migration.sql
new file mode 100644
index 0000000..f43c314
--- /dev/null
+++ b/prisma/migrations/20260221000000_add_test_isolation_anthropic_remove_locale/migration.sql
@@ -0,0 +1,27 @@
+-- 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");
+
+-- Add provider field to AIUsageLog for cross-provider cost tracking
+ALTER TABLE "AIUsageLog" ADD COLUMN "provider" TEXT;
+
+-- Remove LOCALIZATION from SettingCategory enum
+-- First delete any rows using this category to avoid FK constraint errors
+DELETE FROM "SystemSettings" WHERE "category" = 'LOCALIZATION';
+
+-- Remove the enum value (PostgreSQL does not support DROP VALUE directly,
+-- so we recreate the enum type without the removed value)
+-- Step 1: Create new enum without LOCALIZATION
+CREATE TYPE "SettingCategory_new" AS ENUM ('AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP', 'AUDIT_CONFIG', 'DIGEST', 'ANALYTICS', 'INTEGRATIONS', 'COMMUNICATION', 'FEATURE_FLAGS');
+
+-- Step 2: Alter column to use new enum
+ALTER TABLE "SystemSettings" ALTER COLUMN "category" TYPE "SettingCategory_new" USING ("category"::text::"SettingCategory_new");
+
+-- Step 3: Drop old enum and rename new one
+DROP TYPE "SettingCategory";
+ALTER TYPE "SettingCategory_new" RENAME TO "SettingCategory";
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index c313cda..1f8548f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -101,7 +101,6 @@ enum SettingCategory {
DEFAULTS
WHATSAPP
AUDIT_CONFIG
- LOCALIZATION
DIGEST
ANALYTICS
INTEGRATIONS
@@ -351,6 +350,9 @@ model User {
preferredWorkload Int?
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
+ // Test environment isolation
+ isTest Boolean @default(false)
+
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
@@ -495,6 +497,9 @@ model Program {
description String?
settingsJson Json? @db.JsonB
+ // Test environment isolation
+ isTest Boolean @default(false)
+
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -619,6 +624,9 @@ model Project {
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
+ // Test environment isolation
+ isTest Boolean @default(false)
+
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -907,7 +915,8 @@ model AIUsageLog {
entityId String?
// What was used
- model String // gpt-4o, gpt-4o-mini, o1, etc.
+ provider String? // 'openai', 'anthropic', 'litellm'
+ model String // gpt-4o, gpt-4o-mini, o1, claude-sonnet-4-5, etc.
promptTokens Int
completionTokens Int
totalTokens Int
@@ -2090,6 +2099,9 @@ model Competition {
notifyOnDeadlineApproach Boolean @default(true)
deadlineReminderDays Int[] @default([7, 3, 1])
+ // Test environment isolation
+ isTest Boolean @default(false)
+
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2104,6 +2116,7 @@ model Competition {
@@index([programId])
@@index([status])
+ @@index([isTest])
}
model Round {
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx
index b3edd71..39793f5 100644
--- a/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx
+++ b/src/app/(admin)/admin/competitions/[competitionId]/assignments/page.tsx
@@ -68,8 +68,19 @@ export default function AssignmentsDashboardPage() {
if (!competition) {
return (
-
-
Competition not found
+
+
+
+ Competition not found
+
+ The requested competition does not exist or you don't have access.
+
+ router.back()}>
+
+ Go Back
+
+
+
)
}
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/awards/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/awards/page.tsx
index e92d644..1c4a30f 100644
--- a/src/app/(admin)/admin/competitions/[competitionId]/awards/page.tsx
+++ b/src/app/(admin)/admin/competitions/[competitionId]/awards/page.tsx
@@ -13,16 +13,34 @@ import type { Route } from 'next';
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
const params = use(paramsPromise);
const router = useRouter();
- const { data: competition } = trpc.competition.getById.useQuery({
+ const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery({
id: params.competitionId
});
- const { data: awards, isLoading } = trpc.specialAward.list.useQuery({
+ const { data: awards, isLoading, isError: isAwardsError } = trpc.specialAward.list.useQuery({
programId: competition?.programId
}, {
enabled: !!competition?.programId
});
+ if (isCompError || isAwardsError) {
+ return (
+
+
+
router.back()}>
+
+
+
+
Error Loading Awards
+
+ Could not load competition or awards data. Please try again.
+
+
+
+
+ );
+ }
+
if (isLoading) {
return (
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx
index b52a7b1..a7d6f49 100644
--- a/src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx
+++ b/src/app/(admin)/admin/competitions/[competitionId]/deliberation/page.tsx
@@ -43,13 +43,13 @@ export default function DeliberationListPage({
participantUserIds: [] as string[]
});
- const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
+ const { data: sessions = [], isLoading, isError: isSessionsError } = trpc.deliberation.listSessions.useQuery(
{ competitionId: params.competitionId },
{ enabled: !!params.competitionId }
);
// Get rounds for this competition
- const { data: competition } = trpc.competition.getById.useQuery(
+ const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery(
{ id: params.competitionId },
{ enabled: !!params.competitionId }
);
@@ -121,6 +121,24 @@ export default function DeliberationListPage({
return
{labels[status] || status} ;
};
+ if (isCompError || isSessionsError) {
+ return (
+
+
+
router.back()} aria-label="Go back">
+
+
+
+
Error Loading Deliberations
+
+ Could not load competition or deliberation data. Please try again.
+
+
+
+
+ );
+ }
+
if (isLoading) {
return (
diff --git a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx
index db09ecb..d0e6ebe 100644
--- a/src/app/(admin)/admin/competitions/[competitionId]/page.tsx
+++ b/src/app/(admin)/admin/competitions/[competitionId]/page.tsx
@@ -48,6 +48,7 @@ import {
Loader2,
Plus,
CalendarDays,
+ Radio,
} from 'lucide-react'
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
@@ -435,6 +436,19 @@ export default function CompetitionDetailPage() {
{round.juryGroup.name}
)}
+
+ {/* Live Control link for LIVE_FINAL rounds */}
+ {round.roundType === 'LIVE_FINAL' && (
+
e.stopPropagation()}
+ >
+
+
+ Live Control
+
+
+ )}
diff --git a/src/app/(admin)/admin/mentors/[id]/page.tsx b/src/app/(admin)/admin/mentors/[id]/page.tsx
deleted file mode 100644
index bf8a279..0000000
--- a/src/app/(admin)/admin/mentors/[id]/page.tsx
+++ /dev/null
@@ -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}`)
-}
diff --git a/src/app/(admin)/admin/mentors/page.tsx b/src/app/(admin)/admin/mentors/page.tsx
deleted file mode 100644
index 4a655f8..0000000
--- a/src/app/(admin)/admin/mentors/page.tsx
+++ /dev/null
@@ -1,5 +0,0 @@
-import { redirect } from 'next/navigation'
-
-export default function MentorsPage() {
- redirect('/admin/members')
-}
diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx
index 6690b67..f09ee3b 100644
--- a/src/app/(admin)/admin/page.tsx
+++ b/src/app/(admin)/admin/page.tsx
@@ -30,7 +30,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
if (!editionId) {
const defaultEdition = await prisma.program.findFirst({
- where: { status: 'ACTIVE' },
+ where: { status: 'ACTIVE', isTest: false },
orderBy: { year: 'desc' },
select: { id: true },
})
@@ -38,6 +38,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
if (!editionId) {
const anyEdition = await prisma.program.findFirst({
+ where: { isTest: false },
orderBy: { year: 'desc' },
select: { id: true },
})
diff --git a/src/app/(admin)/admin/programs/[id]/page.tsx b/src/app/(admin)/admin/programs/[id]/page.tsx
index b6ebce1..61d539d 100644
--- a/src/app/(admin)/admin/programs/[id]/page.tsx
+++ b/src/app/(admin)/admin/programs/[id]/page.tsx
@@ -19,7 +19,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
-import { ArrowLeft, Pencil, Plus } from 'lucide-react'
+import { ArrowLeft, GraduationCap, Pencil, Plus } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
interface ProgramDetailPageProps {
@@ -65,12 +65,20 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
-
-
-
- Edit
-
-
+
+
+
+
+ Mentorship
+
+
+
+
+
+ Edit
+
+
+
{program.description && (
diff --git a/src/app/(admin)/admin/programs/page.tsx b/src/app/(admin)/admin/programs/page.tsx
index 0ee5e69..176235b 100644
--- a/src/app/(admin)/admin/programs/page.tsx
+++ b/src/app/(admin)/admin/programs/page.tsx
@@ -40,7 +40,7 @@ import { formatDateOnly } from '@/lib/utils'
async function ProgramsContent() {
const programs = await prisma.program.findMany({
- // Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
+ where: { isTest: false },
include: {
competitions: {
include: {
diff --git a/src/app/(admin)/admin/rounds/page.tsx b/src/app/(admin)/admin/rounds/page.tsx
index 210735f..a044227 100644
--- a/src/app/(admin)/admin/rounds/page.tsx
+++ b/src/app/(admin)/admin/rounds/page.tsx
@@ -205,14 +205,14 @@ export default function RoundsPage() {
}
const startEditSettings = () => {
- if (!comp) return
+ if (!comp || !compDetail) return
setEditingCompId(comp.id)
setCompetitionEdits({
- name: comp.name,
- categoryMode: (comp as any).categoryMode,
- startupFinalistCount: (comp as any).startupFinalistCount,
- conceptFinalistCount: (comp as any).conceptFinalistCount,
- notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach,
+ name: compDetail.name,
+ categoryMode: compDetail.categoryMode,
+ startupFinalistCount: compDetail.startupFinalistCount,
+ conceptFinalistCount: compDetail.conceptFinalistCount,
+ notifyOnDeadlineApproach: compDetail.notifyOnDeadlineApproach,
})
setSettingsOpen(true)
}
diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx
index 7895f60..4c10189 100644
--- a/src/app/(admin)/layout.tsx
+++ b/src/app/(admin)/layout.tsx
@@ -12,6 +12,7 @@ export default async function AdminLayout({
// Fetch all editions (programs) for the edition selector
const editions = await prisma.program.findMany({
+ where: { isTest: false },
select: {
id: true,
name: true,
diff --git a/src/app/api/cron/draft-cleanup/route.ts b/src/app/api/cron/draft-cleanup/route.ts
index 1696b5f..f6b4911 100644
--- a/src/app/api/cron/draft-cleanup/route.ts
+++ b/src/app/api/cron/draft-cleanup/route.ts
@@ -13,8 +13,10 @@ export async function GET(request: NextRequest): Promise {
const now = new Date()
// Delete projects where isDraft=true AND draftExpiresAt has passed
+ // Exclude test projects — they are managed separately
const result = await prisma.project.deleteMany({
where: {
+ isTest: false,
isDraft: true,
draftExpiresAt: {
lt: now,
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 23b1a95..5bdfdad 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import './globals.css'
import { Providers } from './providers'
import { Toaster } from 'sonner'
+import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
export const metadata: Metadata = {
title: {
@@ -22,7 +23,10 @@ export default function RootLayout({
return (
- {children}
+
+
+ {children}
+
(null)
- const [deletingWindow, setDeletingWindow] = useState(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 (
-
-
-
-
-
-
Submission Windows
-
- File upload windows for this round
-
-
-
-
-
-
- Create Window
-
-
-
-
- Create Submission Window
-
-
-
- Window Name
- handleCreateNameChange(e.target.value)}
- />
-
-
-
- Slug
- setCreateForm({ ...createForm, slug: e.target.value })}
- />
-
-
-
- Round Number
- setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
- />
-
-
-
- Window Open At
- setCreateForm({ ...createForm, windowOpenAt: e.target.value })}
- />
-
-
-
- Window Close At
- setCreateForm({ ...createForm, windowCloseAt: e.target.value })}
- />
-
-
-
- Deadline Policy
-
- setCreateForm({ ...createForm, deadlinePolicy: value })
- }
- >
-
-
-
-
- Hard Deadline
- Flag Late Submissions
- Grace Period
-
-
-
-
- {createForm.deadlinePolicy === 'GRACE' && (
-
- Grace Hours
- setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })}
- />
-
- )}
-
-
- setCreateForm({ ...createForm, lockOnClose: checked })}
- />
-
- Lock window on close
-
-
-
-
- setIsCreateOpen(false)}
- >
- Cancel
-
-
- {createWindowMutation.isPending && (
-
- )}
- Create
-
-
-
-
-
-
-
-
- {isLoading ? (
-
- Loading windows...
-
- ) : windows.length === 0 ? (
-
- No submission windows yet. Create one to enable file uploads.
-
- ) : (
-
- {windows.map((window) => {
- const isPending = !window.windowOpenAt
- const isOpen = window.windowOpenAt && !window.windowCloseAt
- const isClosed = window.windowCloseAt && !window.isLocked
- const isLocked = window.isLocked
-
- return (
-
-
-
-
-
{window.name}
- {isPending && (
-
- Pending
-
- )}
- {isOpen && (
-
- Open
-
- )}
- {isClosed && (
-
- Closed
-
- )}
- {isLocked && (
-
-
- Locked
-
- )}
-
-
-
{window.slug}
-
- Round {window.roundNumber}
- •
- {window._count.fileRequirements} requirements
- •
- {window._count.projectFiles} files
-
-
- Open: {formatDate(window.windowOpenAt)}
- •
- Close: {formatDate(window.windowCloseAt)}
-
-
-
-
-
openEditDialog(window)}
- className="h-8 px-2"
- >
-
-
-
setDeletingWindow(window.id)}
- className="h-8 px-2 text-destructive hover:text-destructive"
- >
-
-
- {isPending && (
-
openWindowMutation.mutate({ windowId: window.id })}
- disabled={openWindowMutation.isPending}
- >
- {openWindowMutation.isPending ? (
-
- ) : (
-
- )}
- Open
-
- )}
- {isOpen && (
-
closeWindowMutation.mutate({ windowId: window.id })}
- disabled={closeWindowMutation.isPending}
- >
- {closeWindowMutation.isPending ? (
-
- ) : (
-
- )}
- Close
-
- )}
- {isClosed && (
-
lockWindowMutation.mutate({ windowId: window.id })}
- disabled={lockWindowMutation.isPending}
- >
- {lockWindowMutation.isPending ? (
-
- ) : (
-
- )}
- Lock
-
- )}
-
-
-
- )
- })}
-
- )}
-
-
-
- {/* Edit Dialog */}
-
!open && setEditingWindow(null)}>
-
-
- Edit Submission Window
-
-
-
- Window Name
- handleEditNameChange(e.target.value)}
- />
-
-
-
- Slug
- setEditForm({ ...editForm, slug: e.target.value })}
- />
-
-
-
- Round Number
- setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
- />
-
-
-
- Window Open At
- setEditForm({ ...editForm, windowOpenAt: e.target.value })}
- />
-
-
-
- Window Close At
- setEditForm({ ...editForm, windowCloseAt: e.target.value })}
- />
-
-
-
- Deadline Policy
-
- setEditForm({ ...editForm, deadlinePolicy: value })
- }
- >
-
-
-
-
- Hard Deadline
- Flag Late Submissions
- Grace Period
-
-
-
-
- {editForm.deadlinePolicy === 'GRACE' && (
-
- Grace Hours
- setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })}
- />
-
- )}
-
-
- setEditForm({ ...editForm, lockOnClose: checked })}
- />
-
- Lock window on close
-
-
-
-
- Sort Order
- setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })}
- />
-
-
-
- setEditingWindow(null)}
- >
- Cancel
-
-
- {updateWindowMutation.isPending && (
-
- )}
- Save Changes
-
-
-
-
-
-
- {/* Delete Confirmation Dialog */}
-
!open && setDeletingWindow(null)}>
-
-
- Delete Submission Window
-
- 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 && (
-
- Warning: This window has uploaded files and cannot be deleted until they are removed.
-
- )}
-
-
-
- setDeletingWindow(null)}
- >
- Cancel
-
-
- {deleteWindowMutation.isPending && (
-
- )}
- Delete
-
-
-
-
-
- )
-}
\ No newline at end of file
diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx
index 409f85c..334ae5a 100644
--- a/src/components/layouts/admin-sidebar.tsx
+++ b/src/components/layouts/admin-sidebar.tsx
@@ -226,7 +226,8 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{navigation.map((item) => {
const isActive =
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 (
{dynamicAdminNav.map((item) => {
+ const isDisabled = item.name === 'Apply Page' && !currentEdition?.id
let isActive = pathname.startsWith(item.href)
if (item.activeMatch) {
isActive = pathname.includes(item.activeMatch)
} else if (item.activeExclude && pathname.includes(item.activeExclude)) {
isActive = false
}
+ if (isDisabled) {
+ return (
+
+
+ {item.name}
+
+ )
+ }
return (
{
+ 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)
const {
@@ -119,6 +139,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
if (data.openai_api_key && data.openai_api_key.trim()) {
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)
settingsToUpdate.push({ key: 'openai_base_url', value: data.openai_base_url?.trim() || '' })
@@ -139,6 +162,9 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)
const categoryLabels: Record
= {
+ '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-4o': 'GPT-4o Series',
'gpt-4': 'GPT-4 Series',
@@ -147,7 +173,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
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 (
- {isLiteLLM || modelsData?.manualEntry ? (
+ {isAnthropic ? (
+ // Anthropic: fetch models from server (hardcoded list)
+ modelsLoading ? (
+
+ ) : modelsData?.success && modelsData.models && modelsData.models.length > 0 ? (
+
+
+
+
+
+
+
+ {categoryOrder
+ .filter((cat) => groupedModels?.[cat]?.length)
+ .map((category) => (
+
+
+ {categoryLabels[category] || category}
+
+ {groupedModels?.[category]?.map((model) => (
+
+ {model.name}
+
+ ))}
+
+ ))}
+
+
+ ) : (
+ field.onChange(e.target.value)}
+ placeholder="claude-sonnet-4-5-20250514"
+ />
+ )
+ ) : isLiteLLM || modelsData?.manualEntry ? (
field.onChange(e.target.value)}
@@ -341,7 +443,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)}
- {isLiteLLM ? (
+ {isAnthropic ? (
+ form.watch('ai_model')?.includes('opus') ? (
+
+
+ Opus model — includes extended thinking for deeper analysis
+
+ ) : (
+ 'Anthropic Claude model to use for AI features'
+ )
+ ) : isLiteLLM ? (
<>
Enter the model ID with the{' '}
chatgpt/ prefix.
diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx
index 16590ca..a53373a 100644
--- a/src/components/settings/settings-content.tsx
+++ b/src/components/settings/settings-content.tsx
@@ -23,14 +23,15 @@ import {
Newspaper,
BarChart3,
ShieldAlert,
- Globe,
Webhook,
MessageCircle,
+ FlaskConical,
} from 'lucide-react'
import Link from 'next/link'
import { AnimatedCard } from '@/components/shared/animated-container'
import { AISettingsForm } from './ai-settings-form'
import { AIUsageCard } from './ai-usage-card'
+import { TestEnvironmentPanel } from './test-environment-panel'
import { BrandingSettingsForm } from './branding-settings-form'
import { EmailSettingsForm } from './email-settings-form'
import { StorageSettingsForm } from './storage-settings-form'
@@ -158,11 +159,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
'whatsapp_provider',
])
- const localizationSettings = getSettingsByKeys([
- 'localization_enabled_locales',
- 'localization_default_locale',
- ])
-
return (
<>
@@ -176,10 +172,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Branding
-
-
- Locale
-
{isSuperAdmin && (
@@ -236,6 +228,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Webhooks
)}
+ {isSuperAdmin && (
+
+
+ Test Env
+
+ )}
@@ -253,10 +251,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Branding
-
-
- Locale
-
@@ -333,6 +327,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
Webhooks
+
+
+
+ Test Env
+
+
)}
@@ -510,22 +510,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
-
-
-
-
- Localization
-
- Configure language and locale settings
-
-
-
-
-
-
-
-
-
{isSuperAdmin && (
@@ -543,6 +527,28 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
)}
+
+ {isSuperAdmin && (
+
+
+
+
+
+
+ Test Environment
+
+
+ Create a sandboxed test competition with dummy data for testing all roles and workflows.
+ Fully isolated from production data.
+
+
+
+
+
+
+
+
+ )}
{/* end content area */}
{/* end lg:flex */}
@@ -858,66 +864,3 @@ function WhatsAppSettingsSection({ settings }: { settings: Record }) {
- 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 (
-
-
-
Enabled Languages
-
-
-
- EN
- English
-
-
toggleLocale('en')}
- disabled={mutation.isPending}
- />
-
-
-
- FR
- Français
-
-
toggleLocale('fr')}
- disabled={mutation.isPending}
- />
-
-
-
-
-
- )
-}
diff --git a/src/components/settings/test-environment-panel.tsx b/src/components/settings/test-environment-panel.tsx
new file mode 100644
index 0000000..8e182af
--- /dev/null
+++ b/src/components/settings/test-environment-panel.tsx
@@ -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 = {
+ JURY_MEMBER: 'Jury Member',
+ APPLICANT: 'Applicant',
+ MENTOR: 'Mentor',
+ OBSERVER: 'Observer',
+ AWARD_MASTER: 'Award Master',
+ PROGRAM_ADMIN: 'Program Admin',
+}
+
+const ROLE_COLORS: Record = {
+ 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 = {
+ 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 (
+
+
+
+ )
+ }
+
+ // No test environment — show creation card
+ if (!status?.active) {
+ return (
+
+
+
+
No Test Environment
+
+ Create a sandboxed test competition with dummy users, projects, jury assignments,
+ and partial evaluations. All test data is fully isolated from production.
+
+
createMutation.mutate()}
+ disabled={createMutation.isPending}
+ >
+ {createMutation.isPending ? (
+ <>
+
+ Creating test environment...
+ >
+ ) : (
+ <>
+
+ Create Test Competition
+ >
+ )}
+
+ {createMutation.isError && (
+
+ {createMutation.error.message}
+
+ )}
+
+
+ )
+ }
+
+ // 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
+ )
+
+ 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 (
+
+ {/* Status header */}
+
+
+ {/* Quick stats */}
+
+
+
{rounds.length}
+
Rounds
+
+
+
{users.length}
+
Test Users
+
+
+
+ {emailRedirect || '—'}
+
+
Email Redirect
+
+
+
+ {/* Impersonation section */}
+
+
+
+
Impersonate Test User
+
+
+ {Object.entries(roleGroups).map(([role, roleUsers]) => (
+
+
+
+
+ {ROLE_LABELS[role] || role}
+
+
+ {roleUsers.length} user{roleUsers.length !== 1 ? 's' : ''}
+
+
+
+
+ {roleUsers.slice(0, 3).map((u) => (
+ 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"
+ >
+ {u.name || u.email}
+
+ Impersonate
+
+
+ ))}
+ {roleUsers.length > 3 && (
+
+ +{roleUsers.length - 3} more (switch via banner)
+
+ )}
+
+
+ ))}
+
+
+
+ {/* Tear down */}
+
+
+
+
+
+ Tear Down Test Environment
+
+
+
+
+
+
+ Destroy Test Environment
+
+
+ This will permanently delete ALL test data: users, projects, competitions,
+ assignments, evaluations, and files. This action cannot be undone.
+
+
+
+
+ Type DELETE TEST to confirm:
+
+
setConfirmText(e.target.value)}
+ placeholder="DELETE TEST"
+ className="font-mono"
+ />
+
+
+ setConfirmText('')}>
+ Cancel
+
+
+ {tearDownMutation.isPending ? (
+ <>
+
+ Tearing down...
+ >
+ ) : (
+ 'Destroy Test Environment'
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/shared/impersonation-banner.tsx b/src/components/shared/impersonation-banner.tsx
new file mode 100644
index 0000000..2594ef8
--- /dev/null
+++ b/src/components/shared/impersonation-banner.tsx
@@ -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 = {
+ 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 = {
+ 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
+ )
+
+ 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 (
+
+
+
+
+
+ Viewing as {currentName} {' '}
+
+ {ROLE_LABELS[currentRole] || currentRole}
+
+
+
+
+
+ {/* Quick-switch dropdown */}
+
+
+
+ Switch Role
+
+
+
+
+ {Object.entries(roleGroups).map(([role, users]) => (
+
+
+ {ROLE_LABELS[role] || role}
+
+ {users.map((u) => (
+ handleSwitch(u.id, u.role as UserRole)}
+ disabled={switching}
+ >
+ {u.name || u.email}
+
+ ))}
+
+
+ ))}
+
+
+
+ {/* Return to admin */}
+
+
+ Return to Admin
+
+
+
+
+ )
+}
diff --git a/src/lib/auth.config.ts b/src/lib/auth.config.ts
index 1d62279..0847fbc 100644
--- a/src/lib/auth.config.ts
+++ b/src/lib/auth.config.ts
@@ -10,6 +10,11 @@ declare module 'next-auth' {
name?: string | null
role: UserRole
mustSetPassword?: boolean
+ // Impersonation fields
+ isImpersonating?: boolean
+ realUserId?: string
+ realRole?: UserRole
+ impersonatedName?: string | null
}
}
@@ -24,6 +29,12 @@ declare module '@auth/core/jwt' {
id: string
role: UserRole
mustSetPassword?: boolean
+ // Impersonation fields
+ impersonatedUserId?: string
+ impersonatedRole?: UserRole
+ impersonatedName?: string | null
+ realUserId?: string
+ realRole?: UserRole
}
}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 473a768..6ae020c 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -190,7 +190,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
],
callbacks: {
...authConfig.callbacks,
- async jwt({ token, user, trigger }) {
+ async jwt({ token, user, trigger, session: sessionUpdate }) {
// Initial sign in
if (user) {
token.id = user.id as string
@@ -198,15 +198,48 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
token.mustSetPassword = user.mustSetPassword
}
- // On session update, refresh from database
+ // On session update
if (trigger === 'update') {
- 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
+ // Handle impersonation request
+ if (sessionUpdate?.impersonateUserId) {
+ const testUser = await prisma.user.findUnique({
+ where: { id: sessionUpdate.impersonateUserId },
+ select: { id: true, name: true, email: true, role: true, isTest: true },
+ })
+ // 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.role = token.role as UserRole
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
},
diff --git a/src/lib/email.ts b/src/lib/email.ts
index 36809bc..f47c9a8 100644
--- a/src/lib/email.ts
+++ b/src/lib/email.ts
@@ -7,6 +7,32 @@ let cachedTransporter: Transporter | null = null
let cachedConfigHash = ''
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.
* Caches the transporter and rebuilds it when settings change.
@@ -47,12 +73,31 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
}
// Create new transporter
- cachedTransporter = nodemailer.createTransport({
+ const rawTransporter = nodemailer.createTransport({
host,
port: parseInt(port),
secure: port === '465',
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
cachedFrom = from
diff --git a/src/lib/openai.ts b/src/lib/openai.ts
index d57ee0c..1c78576 100644
--- a/src/lib/openai.ts
+++ b/src/lib/openai.ts
@@ -1,10 +1,36 @@
import OpenAI from 'openai'
import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat/completions'
+import Anthropic from '@anthropic-ai/sdk'
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 client singleton with lazy initialization
const globalForOpenAI = globalThis as unknown as {
- openai: OpenAI | undefined
+ openai: AIClient | undefined
openaiInitialized: boolean
}
@@ -12,15 +38,17 @@ const globalForOpenAI = globalThis as unknown as {
/**
* 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 {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'ai_provider' },
})
const value = setting?.value || 'openai'
- return value === 'litellm' ? 'litellm' : 'openai'
+ if (value === 'litellm') return 'litellm'
+ if (value === 'anthropic') return 'anthropic'
+ return 'openai'
} catch {
return 'openai'
}
@@ -219,6 +247,20 @@ async function getOpenAIApiKey(): Promise {
}
}
+/**
+ * Get Anthropic API key from SystemSettings
+ */
+async function getAnthropicApiKey(): Promise {
+ 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.
* Supports OpenRouter, Together AI, Groq, local models, etc.
@@ -265,15 +307,165 @@ async function createOpenAIClient(): Promise {
}
/**
- * Get the OpenAI client singleton
- * Returns null if API key is not configured
+ * Check if a model is a Claude Opus model (supports extended thinking).
*/
-export async function getOpenAI(): Promise {
+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 {
+ 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 {
+ // 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 {
if (globalForOpenAI.openaiInitialized) {
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') {
globalForOpenAI.openai = client || undefined
@@ -298,10 +490,13 @@ export function resetOpenAIClient(): void {
export async function isOpenAIConfigured(): Promise {
const provider = await getConfiguredProvider()
if (provider === 'litellm') {
- // LiteLLM just needs a base URL configured
const baseURL = await getBaseURL()
return !!baseURL
}
+ if (provider === 'anthropic') {
+ const apiKey = await getAnthropicApiKey()
+ return !!apiKey
+ }
const apiKey = await getOpenAIApiKey()
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()
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
.filter((m) => m.id.includes('gpt') || m.id.includes('o1') || m.id.includes('o3') || m.id.includes('o4'))
.map((m) => m.id)
@@ -367,14 +574,16 @@ export async function validateModel(modelId: string): Promise<{
if (!client) {
return {
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, {
messages: [{ role: 'user', content: 'test' }],
- maxTokens: 1,
+ maxTokens: provider === 'anthropic' ? 16 : 1,
})
await client.chat.completions.create(params)
@@ -407,11 +616,13 @@ export async function testOpenAIConnection(): Promise<{
}> {
try {
const client = await getOpenAI()
+ const provider = await getConfiguredProvider()
if (!client) {
+ const label = provider === 'anthropic' ? 'Anthropic' : 'OpenAI'
return {
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
const params = buildCompletionParams(configuredModel, {
messages: [{ role: 'user', content: 'Hello' }],
- maxTokens: 5,
+ maxTokens: provider === 'anthropic' ? 16 : 5,
})
const response = await client.chat.completions.create(params)
@@ -436,7 +647,7 @@ export async function testOpenAIConnection(): Promise<{
const configuredModel = await getConfiguredModel()
// 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 {
success: false,
error: `Model "${configuredModel}" is not available. Check Settings → AI to select a valid model.`,
diff --git a/src/server/routers/_app.ts b/src/server/routers/_app.ts
index e76cbf3..6a84f0b 100644
--- a/src/server/routers/_app.ts
+++ b/src/server/routers/_app.ts
@@ -51,6 +51,7 @@ import { roundEngineRouter } from './roundEngine'
import { roundAssignmentRouter } from './roundAssignment'
import { deliberationRouter } from './deliberation'
import { resultLockRouter } from './resultLock'
+import { testEnvironmentRouter } from './testEnvironment'
/**
* Root tRPC router that combines all domain routers
@@ -108,6 +109,8 @@ export const appRouter = router({
roundAssignment: roundAssignmentRouter,
deliberation: deliberationRouter,
resultLock: resultLockRouter,
+ // Test environment
+ testEnvironment: testEnvironmentRouter,
})
export type AppRouter = typeof appRouter
diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts
index f5eb023..0367cf0 100644
--- a/src/server/routers/analytics.ts
+++ b/src/server/routers/analytics.ts
@@ -12,8 +12,8 @@ const editionOrRoundInput = z.object({
})
function projectWhere(input: { roundId?: string; programId?: string }) {
- if (input.roundId) return { projectRoundStates: { some: { roundId: input.roundId } } }
- return { programId: input.programId! }
+ if (input.roundId) return { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } }
+ return { isTest: false, programId: input.programId! }
}
function assignmentWhere(input: { roundId?: string; programId?: string }) {
@@ -263,7 +263,7 @@ export const analyticsRouter = router({
if (round?.roundType === 'EVALUATION') {
// For evaluation rounds, break down by evaluation status per project
const projects = await ctx.prisma.projectRoundState.findMany({
- where: { roundId: input.roundId },
+ where: { roundId: input.roundId, project: { isTest: false } },
select: {
projectId: true,
project: {
@@ -309,7 +309,7 @@ export const analyticsRouter = router({
// Non-evaluation rounds: use ProjectRoundState
const states = await ctx.prisma.projectRoundState.groupBy({
by: ['state'],
- where: { roundId: input.roundId },
+ where: { roundId: input.roundId, project: { isTest: false } },
_count: true,
})
return states.map((s) => ({
@@ -469,8 +469,8 @@ export const analyticsRouter = router({
)
.query(async ({ ctx, input }) => {
const where = input.roundId
- ? { assignments: { some: { roundId: input.roundId } } }
- : { programId: input.programId }
+ ? { isTest: false, assignments: { some: { roundId: input.roundId } } }
+ : { isTest: false, programId: input.programId }
const distribution = await ctx.prisma.project.groupBy({
by: ['country'],
@@ -537,7 +537,7 @@ export const analyticsRouter = router({
// Count distinct projects per round via assignments
const projectAssignments = await ctx.prisma.assignment.findMany({
- where: { roundId: { in: roundIds } },
+ where: { roundId: { in: roundIds }, project: { isTest: false } },
select: { roundId: true, projectId: true },
distinct: ['roundId', 'projectId'],
})
@@ -714,12 +714,14 @@ export const analyticsRouter = router({
const roundId = input?.roundId
const projectFilter = roundId
- ? { projectRoundStates: { some: { roundId } } }
- : {}
- const assignmentFilter = roundId ? { roundId } : {}
+ ? { isTest: false, projectRoundStates: { some: { roundId } } }
+ : { isTest: false }
+ const assignmentFilter = roundId
+ ? { roundId }
+ : { round: { competition: { isTest: false } } }
const evalFilter = roundId
? { assignment: { roundId }, status: 'SUBMITTED' as const }
- : { status: 'SUBMITTED' as const }
+ : { assignment: { round: { competition: { isTest: false } } }, status: 'SUBMITTED' as const }
const [
programCount,
@@ -730,9 +732,9 @@ export const analyticsRouter = router({
totalAssignments,
evaluationScores,
] = await Promise.all([
- ctx.prisma.program.count(),
+ ctx.prisma.program.count({ where: { isTest: false } }),
ctx.prisma.round.findMany({
- where: { status: 'ROUND_ACTIVE' },
+ where: { status: 'ROUND_ACTIVE', competition: { isTest: false } },
select: { id: true, name: true },
take: 5,
}),
@@ -743,7 +745,7 @@ export const analyticsRouter = router({
select: { userId: true },
distinct: ['userId'],
}).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.assignment.count({ where: assignmentFilter }),
ctx.prisma.evaluation.findMany({
@@ -988,7 +990,7 @@ export const analyticsRouter = router({
})
)
.query(async ({ ctx, input }) => {
- const where: Record = {}
+ const where: Record = { isTest: false }
if (input.roundId) {
where.projectRoundStates = { some: { roundId: input.roundId } }
@@ -1151,15 +1153,15 @@ export const analyticsRouter = router({
switch (roundType) {
case 'INTAKE': {
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({
by: ['state'],
- where: { roundId: input.roundId },
+ where: { roundId: input.roundId, project: { isTest: false } },
_count: true,
}),
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
- where: { projectRoundStates: { some: { roundId: input.roundId } } },
+ where: { isTest: false, projectRoundStates: { some: { roundId: input.roundId } } },
_count: true,
}),
])
@@ -1395,7 +1397,7 @@ export const analyticsRouter = router({
// Get competition rounds for file grouping
let competitionRounds: { id: string; name: string; roundType: string }[] = []
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' } } },
})
if (competition) {
@@ -1478,9 +1480,23 @@ export const analyticsRouter = router({
.query(async ({ ctx, input }) => {
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({
orderBy: { createdAt: 'desc' },
take: limit,
+ ...(testUserIds.length > 0 && {
+ where: {
+ OR: [
+ { actorId: null },
+ { actorId: { notIn: testUserIds } },
+ ],
+ },
+ }),
select: {
id: 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 actors = actorIds.length > 0
? await ctx.prisma.user.findMany({
- where: { id: { in: actorIds } },
+ where: { id: { in: actorIds }, isTest: false },
select: { id: true, name: true },
})
: []
diff --git a/src/server/routers/application.ts b/src/server/routers/application.ts
index 999cef3..6a6dd0c 100644
--- a/src/server/routers/application.ts
+++ b/src/server/routers/application.ts
@@ -105,7 +105,7 @@ export const applicationRouter = router({
if (input.mode === 'edition') {
// Edition-wide application mode
const program = await ctx.prisma.program.findFirst({
- where: { slug: input.slug },
+ where: { slug: input.slug, isTest: false },
})
if (!program) {
@@ -687,6 +687,7 @@ export const applicationRouter = router({
const projects = await ctx.prisma.project.findMany({
where: {
isDraft: true,
+ isTest: false,
},
})
@@ -837,6 +838,7 @@ export const applicationRouter = router({
const projects = await ctx.prisma.project.findMany({
where: {
isDraft: true,
+ isTest: false,
},
})
diff --git a/src/server/routers/assignment.ts b/src/server/routers/assignment.ts
index b9dd7d2..7806e47 100644
--- a/src/server/routers/assignment.ts
+++ b/src/server/routers/assignment.ts
@@ -560,6 +560,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
+ isTest: false,
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
},
select: {
@@ -1255,6 +1256,7 @@ export const assignmentRouter = router({
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
+ isTest: false,
...(scopedJurorIds ? { id: { in: scopedJurorIds } } : {}),
},
select: {
diff --git a/src/server/routers/competition.ts b/src/server/routers/competition.ts
index 8292cd7..16e1730 100644
--- a/src/server/routers/competition.ts
+++ b/src/server/routers/competition.ts
@@ -142,7 +142,7 @@ export const competitionRouter = router({
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.prisma.competition.findMany({
- where: { programId: input.programId },
+ where: { programId: input.programId, isTest: false },
orderBy: { createdAt: 'desc' },
include: {
_count: {
@@ -254,7 +254,7 @@ export const competitionRouter = router({
const competitionIds = [...new Set(memberships.map((m) => m.juryGroup.competitionId))]
if (competitionIds.length === 0) return []
return ctx.prisma.competition.findMany({
- where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' } },
+ where: { id: { in: competitionIds }, status: { not: 'ARCHIVED' }, isTest: false },
include: {
rounds: {
orderBy: { sortOrder: 'asc' },
diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts
index 4880a47..161fa7c 100644
--- a/src/server/routers/dashboard.ts
+++ b/src/server/routers/dashboard.ts
@@ -172,18 +172,19 @@ export const dashboardRouter = router({
// 7. Project count
ctx.prisma.project.count({
- where: { programId: editionId },
+ where: { programId: editionId, isTest: false },
}),
// 8. New projects this week
ctx.prisma.project.count({
- where: { programId: editionId, createdAt: { gte: sevenDaysAgo } },
+ where: { programId: editionId, isTest: false, createdAt: { gte: sevenDaysAgo } },
}),
// 9. Total jurors
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
+ isTest: false,
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
assignments: { some: { round: { competition: { programId: editionId } } } },
},
@@ -193,6 +194,7 @@ export const dashboardRouter = router({
ctx.prisma.user.count({
where: {
role: 'JURY_MEMBER',
+ isTest: false,
status: 'ACTIVE',
assignments: { some: { round: { competition: { programId: editionId } } } },
},
@@ -212,7 +214,7 @@ export const dashboardRouter = router({
// 13. Latest projects
ctx.prisma.project.findMany({
- where: { programId: editionId },
+ where: { programId: editionId, isTest: false },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
@@ -232,20 +234,20 @@ export const dashboardRouter = router({
// 14. Category breakdown
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
- where: { programId: editionId },
+ where: { programId: editionId, isTest: false },
_count: true,
}),
// 15. Ocean issue breakdown
ctx.prisma.project.groupBy({
by: ['oceanIssue'],
- where: { programId: editionId },
+ where: { programId: editionId, isTest: false },
_count: true,
}),
- // 16. Recent activity
+ // 16. Recent activity (exclude test user actions)
ctx.prisma.auditLog.findMany({
- where: { timestamp: { gte: sevenDaysAgo } },
+ where: { timestamp: { gte: sevenDaysAgo }, user: { isTest: false } },
orderBy: { timestamp: 'desc' },
take: 8,
select: {
diff --git a/src/server/routers/export.ts b/src/server/routers/export.ts
index aa24c85..cba563c 100644
--- a/src/server/routers/export.ts
+++ b/src/server/routers/export.ts
@@ -105,6 +105,7 @@ export const exportRouter = router({
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: {
+ isTest: false,
assignments: { some: { roundId: input.roundId } },
},
include: {
@@ -355,7 +356,7 @@ export const exportRouter = router({
}
const logs = await ctx.prisma.auditLog.findMany({
- where,
+ where: { ...where, user: { isTest: false } },
orderBy: { timestamp: 'desc' },
include: {
user: { select: { name: true, email: true } },
@@ -431,7 +432,7 @@ export const exportRouter = router({
if (includeSection('summary')) {
const [projectCount, assignmentCount, evaluationCount, jurorCount] = await Promise.all([
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.evaluation.count({
@@ -486,7 +487,7 @@ export const exportRouter = router({
// Rankings
if (includeSection('rankings')) {
const projects = await ctx.prisma.project.findMany({
- where: { assignments: { some: { roundId: input.roundId } } },
+ where: { isTest: false, assignments: { some: { roundId: input.roundId } } },
select: {
id: true,
title: true,
diff --git a/src/server/routers/file.ts b/src/server/routers/file.ts
index 858bd65..714bddd 100644
--- a/src/server/routers/file.ts
+++ b/src/server/routers/file.ts
@@ -994,6 +994,7 @@ export const fileRouter = router({
// Build project filter
const projectWhere: Record = {
programId: window.competition.programId,
+ isTest: false,
}
if (input.search) {
projectWhere.OR = [
@@ -1303,6 +1304,7 @@ export const fileRouter = router({
// Build project filter
const projectWhere: Record = {
programId: round.competition.programId,
+ isTest: false,
}
if (input.search) {
projectWhere.OR = [
diff --git a/src/server/routers/filtering.ts b/src/server/routers/filtering.ts
index 7ba883e..695863a 100644
--- a/src/server/routers/filtering.ts
+++ b/src/server/routers/filtering.ts
@@ -115,6 +115,7 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
roundId,
exitedAt: null,
state: { in: ['PENDING', 'IN_PROGRESS'] },
+ project: { isTest: false },
},
include: {
project: {
diff --git a/src/server/routers/mentor.ts b/src/server/routers/mentor.ts
index c8e4fbb..b1cab51 100644
--- a/src/server/routers/mentor.ts
+++ b/src/server/routers/mentor.ts
@@ -420,6 +420,7 @@ export const mentorRouter = router({
const projects = await ctx.prisma.project.findMany({
where: {
programId: input.programId,
+ isTest: false,
mentorAssignment: null,
wantsMentorship: true,
},
diff --git a/src/server/routers/message.ts b/src/server/routers/message.ts
index 8471c3a..531b3bb 100644
--- a/src/server/routers/message.ts
+++ b/src/server/routers/message.ts
@@ -402,7 +402,7 @@ async function resolveRecipients(
const role = filter?.role as string
if (!role) return []
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 },
})
return users.map((u) => u.id)
@@ -412,7 +412,7 @@ async function resolveRecipients(
const targetRoundId = roundId || (filter?.roundId as string)
if (!targetRoundId) return []
const assignments = await prisma.assignment.findMany({
- where: { roundId: targetRoundId },
+ where: { roundId: targetRoundId, user: { isTest: false } },
select: { userId: true },
distinct: ['userId'],
})
@@ -423,7 +423,7 @@ async function resolveRecipients(
const programId = filter?.programId as string
if (!programId) return []
const projects = await prisma.project.findMany({
- where: { programId },
+ where: { programId, isTest: false },
select: { submittedByUserId: true },
})
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
@@ -432,7 +432,7 @@ async function resolveRecipients(
case 'ALL': {
const users = await prisma.user.findMany({
- where: { status: 'ACTIVE' },
+ where: { status: 'ACTIVE', isTest: false },
select: { id: true },
})
return users.map((u) => u.id)
diff --git a/src/server/routers/program.ts b/src/server/routers/program.ts
index 61a786c..479e8f1 100644
--- a/src/server/routers/program.ts
+++ b/src/server/routers/program.ts
@@ -22,7 +22,7 @@ export const programRouter = router({
const includeStages = input?.includeStages || false
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' },
include: includeStages
? {
diff --git a/src/server/routers/project-pool.ts b/src/server/routers/project-pool.ts
index b2d2594..766852f 100644
--- a/src/server/routers/project-pool.ts
+++ b/src/server/routers/project-pool.ts
@@ -103,6 +103,7 @@ export const projectPoolRouter = router({
// Build where clause
const where: Record = {
+ isTest: false,
programId,
}
@@ -317,6 +318,7 @@ export const projectPoolRouter = router({
// Find projects to assign
const where: Record = {
+ isTest: false,
programId,
}
diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts
index e4bfcda..f74ef33 100644
--- a/src/server/routers/project.ts
+++ b/src/server/routers/project.ts
@@ -84,7 +84,9 @@ export const projectRouter = router({
const skip = (page - 1) * perPage
// Build where clause
- const where: Record = {}
+ const where: Record = {
+ isTest: false,
+ }
// Filter by program
if (programId) where.programId = programId
@@ -219,7 +221,9 @@ export const projectRouter = router({
wantsMentorship, hasFiles, hasAssignments,
} = input
- const where: Record = {}
+ const where: Record = {
+ isTest: false,
+ }
if (programId) where.programId = programId
if (roundId) {
@@ -357,19 +361,19 @@ export const projectRouter = router({
.query(async ({ ctx }) => {
const [countries, categories, issues] = await Promise.all([
ctx.prisma.project.findMany({
- where: { country: { not: null } },
+ where: { isTest: false, country: { not: null } },
select: { country: true },
distinct: ['country'],
orderBy: { country: 'asc' },
}),
ctx.prisma.project.groupBy({
by: ['competitionCategory'],
- where: { competitionCategory: { not: null } },
+ where: { isTest: false, competitionCategory: { not: null } },
_count: true,
}),
ctx.prisma.project.groupBy({
by: ['oceanIssue'],
- where: { oceanIssue: { not: null } },
+ where: { isTest: false, oceanIssue: { not: null } },
_count: true,
}),
])
@@ -838,7 +842,7 @@ export const projectRouter = router({
)
.mutation(async ({ ctx, input }) => {
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 },
})
@@ -948,11 +952,13 @@ export const projectRouter = router({
programId: z.string().optional(),
}))
.query(async ({ ctx, input }) => {
- const where: Record = {}
+ const where: Record = {
+ isTest: false,
+ }
if (input.programId) where.programId = input.programId
const projects = await ctx.prisma.project.findMany({
- where: Object.keys(where).length > 0 ? where : undefined,
+ where,
select: { tags: true },
})
@@ -984,6 +990,7 @@ export const projectRouter = router({
const projects = await ctx.prisma.project.findMany({
where: {
id: { in: input.ids },
+ isTest: false,
},
select: { id: true, title: true },
})
@@ -1102,6 +1109,7 @@ export const projectRouter = router({
const where: Record = {
programId,
+ isTest: false,
projectRoundStates: { none: {} }, // Projects not assigned to any round
}
diff --git a/src/server/routers/settings.ts b/src/server/routers/settings.ts
index 66c298a..c0b9d3b 100644
--- a/src/server/routers/settings.ts
+++ b/src/server/routers/settings.ts
@@ -17,6 +17,11 @@ function categorizeModel(modelId: string): string {
if (id.startsWith('gpt-4')) return 'gpt-4'
if (id.startsWith('gpt-3.5')) return 'gpt-3.5'
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'
}
@@ -26,16 +31,10 @@ export const settingsRouter = router({
* These are non-sensitive settings that can be exposed to any user
*/
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
- const [whatsappEnabled, defaultLocale, availableLocales, juryCompareEnabled] = await Promise.all([
+ const [whatsappEnabled, juryCompareEnabled] = await Promise.all([
ctx.prisma.systemSettings.findUnique({
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({
where: { key: 'jury_compare_enabled' },
}),
@@ -43,8 +42,6 @@ export const settingsRouter = router({
return {
whatsappEnabled: whatsappEnabled?.value === 'true',
- defaultLocale: defaultLocale?.value || 'en',
- availableLocales: availableLocales?.value ? JSON.parse(availableLocales.value) : ['en', 'fr'],
juryCompareEnabled: juryCompareEnabled?.value === 'true',
}
}),
@@ -171,14 +168,13 @@ export const settingsRouter = router({
)
.mutation(async ({ ctx, input }) => {
// Infer category from key prefix if not provided
- const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' | 'LOCALIZATION' => {
- if (key.startsWith('openai') || key.startsWith('ai_')) return 'AI'
+ const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' => {
+ if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI'
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('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
- if (key.startsWith('i18n_') || key.startsWith('locale_')) return 'LOCALIZATION'
return 'DEFAULTS'
}
@@ -206,7 +202,7 @@ export const settingsRouter = router({
}
// 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')
resetOpenAIClient()
}
@@ -276,9 +272,9 @@ export const settingsRouter = router({
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 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 bOrder = order.findIndex(cat => b.category === cat)
if (aOrder !== bOrder) return aOrder - bOrder
@@ -740,62 +736,4 @@ export const settingsRouter = router({
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
- }),
})
diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts
index 2fac9b2..b8d8a9c 100644
--- a/src/server/routers/specialAward.ts
+++ b/src/server/routers/specialAward.ts
@@ -93,7 +93,7 @@ export const specialAwardRouter = router({
let { competition } = award
if (!competition && award.programId) {
const comp = await ctx.prisma.competition.findFirst({
- where: { programId: award.programId },
+ where: { programId: award.programId, isTest: false },
orderBy: { createdAt: 'desc' },
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
if (!competitionId) {
const comp = await ctx.prisma.competition.findFirst({
- where: { programId: input.programId },
+ where: { programId: input.programId, isTest: false },
orderBy: { createdAt: 'desc' },
select: { id: true },
})
@@ -217,7 +217,7 @@ export const specialAwardRouter = router({
})
if (existing && !existing.competitionId) {
const comp = await ctx.prisma.competition.findFirst({
- where: { programId: existing.programId },
+ where: { programId: existing.programId, isTest: false },
orderBy: { createdAt: 'desc' },
select: { id: true },
})
@@ -404,7 +404,7 @@ export const specialAwardRouter = router({
const { awardId, eligibleOnly, page, perPage } = input
const skip = (page - 1) * perPage
- const where: Record = { awardId }
+ const where: Record = { awardId, project: { isTest: false } }
if (eligibleOnly) where.eligible = true
const [eligibilities, total] = await Promise.all([
diff --git a/src/server/routers/tag.ts b/src/server/routers/tag.ts
index b782ed0..d00df63 100644
--- a/src/server/routers/tag.ts
+++ b/src/server/routers/tag.ts
@@ -53,7 +53,7 @@ async function runTaggingJob(jobId: string, userId: string) {
if (!job.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({
where: whereClause,
@@ -196,11 +196,13 @@ export const tagRouter = router({
const userCount = await ctx.prisma.user.count({
where: {
expertiseTags: { has: tag.name },
+ isTest: false,
},
})
const projectCount = await ctx.prisma.project.count({
where: {
tags: { has: tag.name },
+ isTest: false,
},
})
return {
@@ -228,10 +230,10 @@ export const tagRouter = router({
// Get usage counts
const [userCount, projectCount] = await Promise.all([
ctx.prisma.user.count({
- where: { expertiseTags: { has: tag.name } },
+ where: { expertiseTags: { has: tag.name }, isTest: false },
}),
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
const usersWithTag = await ctx.prisma.user.findMany({
- where: { expertiseTags: { has: oldTag.name } },
+ where: { expertiseTags: { has: oldTag.name }, isTest: false },
select: { id: true, expertiseTags: true },
})
@@ -371,7 +373,7 @@ export const tagRouter = router({
// Update projects
const projectsWithTag = await ctx.prisma.project.findMany({
- where: { tags: { has: oldTag.name } },
+ where: { tags: { has: oldTag.name }, isTest: false },
select: { id: true, tags: true },
})
@@ -412,9 +414,9 @@ export const tagRouter = router({
where: { id: input.id },
})
- // Remove tag from all users
+ // Remove tag from all users (excluding test users)
const usersWithTag = await ctx.prisma.user.findMany({
- where: { expertiseTags: { has: tag.name } },
+ where: { expertiseTags: { has: tag.name }, isTest: false },
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({
- where: { tags: { has: tag.name } },
+ where: { tags: { has: tag.name }, isTest: false },
select: { id: true, tags: true },
})
diff --git a/src/server/routers/testEnvironment.ts b/src/server/routers/testEnvironment.ts
new file mode 100644
index 0000000..e164ce0
--- /dev/null
+++ b/src/server/routers/testEnvironment.ts
@@ -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 }
+ }),
+})
diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts
index 6ce205d..1ee992a 100644
--- a/src/server/routers/user.ts
+++ b/src/server/routers/user.ts
@@ -249,7 +249,9 @@ export const userRouter = router({
const { role, roles, status, search, page, perPage } = input
const skip = (page - 1) * perPage
- const where: Record = {}
+ const where: Record = {
+ isTest: false,
+ }
if (roles && roles.length > 0) {
where.role = { in: roles }
@@ -316,6 +318,7 @@ export const userRouter = router({
)
.query(async ({ ctx, input }) => {
const where: Record = {
+ isTest: false,
status: { in: ['NONE', 'INVITED'] },
}
@@ -929,6 +932,7 @@ export const userRouter = router({
)
.query(async ({ ctx, input }) => {
const where: Record = {
+ isTest: false,
role: 'JURY_MEMBER',
status: 'ACTIVE',
}
diff --git a/src/server/services/ai-evaluation-summary.ts b/src/server/services/ai-evaluation-summary.ts
index 620a81e..c2f45e0 100644
--- a/src/server/services/ai-evaluation-summary.ts
+++ b/src/server/services/ai-evaluation-summary.ts
@@ -290,6 +290,7 @@ export async function generateSummary({
select: {
id: true,
title: true,
+ isTest: true,
},
})
@@ -297,6 +298,10 @@ export async function generateSummary({
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
const evaluations = await prisma.evaluation.findMany({
where: {
diff --git a/src/server/services/ai-shortlist.ts b/src/server/services/ai-shortlist.ts
index 10f1c96..641147e 100644
--- a/src/server/services/ai-shortlist.ts
+++ b/src/server/services/ai-shortlist.ts
@@ -103,6 +103,7 @@ async function generateCategoryShortlist(
const projects = await prisma.project.findMany({
where: {
competitionCategory: category,
+ isTest: false,
assignments: { some: { roundId } },
},
include: {
diff --git a/src/server/services/ai-tagging.ts b/src/server/services/ai-tagging.ts
index d0a7050..f8a3dc7 100644
--- a/src/server/services/ai-tagging.ts
+++ b/src/server/services/ai-tagging.ts
@@ -473,6 +473,10 @@ export async function tagProject(
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
const availableTags = await getAvailableTags()
if (availableTags.length === 0) {
@@ -574,7 +578,7 @@ export async function tagProjectsBatch(
// Fetch full project data for all projects at once (single DB query)
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: {
projectTags: true,
files: { select: { fileType: true } },
@@ -712,6 +716,10 @@ export async function getTagSuggestions(
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
const availableTags = await getAvailableTags()
if (availableTags.length === 0) {
diff --git a/src/server/services/award-eligibility-job.ts b/src/server/services/award-eligibility-job.ts
index 06456c1..9a07266 100644
--- a/src/server/services/award-eligibility-job.ts
+++ b/src/server/services/award-eligibility-job.ts
@@ -88,6 +88,7 @@ export async function processEligibilityJob(
where: {
id: { in: passedIds },
programId: award.programId,
+ isTest: false,
},
select: projectSelect,
})
@@ -99,6 +100,7 @@ export async function processEligibilityJob(
projects = await prisma.project.findMany({
where: {
programId: award.programId,
+ isTest: false,
status: { in: [...statusFilter] },
},
select: projectSelect,
diff --git a/src/server/services/document-analyzer.ts b/src/server/services/document-analyzer.ts
index 1913ae3..f2644d6 100644
--- a/src/server/services/document-analyzer.ts
+++ b/src/server/services/document-analyzer.ts
@@ -320,7 +320,7 @@ export async function analyzeAllUnanalyzed(): Promise<{
total: number
}> {
const files = await prisma.projectFile.findMany({
- where: { analyzedAt: null },
+ where: { analyzedAt: null, project: { isTest: false } },
select: {
id: true,
objectKey: true,
diff --git a/src/server/services/email-digest.ts b/src/server/services/email-digest.ts
index 3a017bc..14fbc51 100644
--- a/src/server/services/email-digest.ts
+++ b/src/server/services/email-digest.ts
@@ -32,6 +32,7 @@ export async function processDigests(
// Find users who opted in for this digest frequency
const users = await prisma.user.findMany({
where: {
+ isTest: false,
digestFrequency: type,
status: 'ACTIVE',
},
diff --git a/src/server/services/evaluation-reminders.ts b/src/server/services/evaluation-reminders.ts
index 260bba3..9bb9270 100644
--- a/src/server/services/evaluation-reminders.ts
+++ b/src/server/services/evaluation-reminders.ts
@@ -56,7 +56,7 @@ export async function sendManualReminders(roundId: string): Promise {
// Get user with notification preferences
const user = await prisma.user.findUnique({
- where: { id: userId },
+ where: { id: userId, isTest: false },
select: {
id: true,
email: true,
diff --git a/src/server/services/round-assignment.ts b/src/server/services/round-assignment.ts
index d094341..a367f92 100644
--- a/src/server/services/round-assignment.ts
+++ b/src/server/services/round-assignment.ts
@@ -94,7 +94,7 @@ export async function previewRoundAssignment(
// Load jury group members
const members = await db.juryGroupMember.findMany({
- where: { juryGroupId: ctx.juryGroup.id },
+ where: { juryGroupId: ctx.juryGroup.id, user: { isTest: false } },
include: {
user: { select: { id: true, name: true, email: true, role: true, bio: true, expertiseTags: true, country: true, availabilityJson: true } },
},
diff --git a/src/server/services/round-scheduler.ts b/src/server/services/round-scheduler.ts
index ac13265..5e76aa6 100644
--- a/src/server/services/round-scheduler.ts
+++ b/src/server/services/round-scheduler.ts
@@ -16,7 +16,7 @@ export async function processScheduledRounds(): Promise<{
// Find a SUPER_ADMIN to use as the actor for audit logging
const systemActor = await prisma.user.findFirst({
- where: { role: 'SUPER_ADMIN' },
+ where: { role: 'SUPER_ADMIN', isTest: false },
select: { id: true },
})
@@ -29,7 +29,7 @@ export async function processScheduledRounds(): Promise<{
where: {
status: 'ROUND_DRAFT',
windowOpenAt: { lte: now },
- competition: { status: { not: 'ARCHIVED' } },
+ competition: { status: { not: 'ARCHIVED' }, isTest: false },
},
select: { id: true, name: true },
})
@@ -49,6 +49,7 @@ export async function processScheduledRounds(): Promise<{
where: {
status: 'ROUND_ACTIVE',
windowCloseAt: { lte: now },
+ competition: { isTest: false },
},
select: { id: true, name: true },
})
diff --git a/src/server/services/smart-assignment.ts b/src/server/services/smart-assignment.ts
index d2dacf2..39280b6 100644
--- a/src/server/services/smart-assignment.ts
+++ b/src/server/services/smart-assignment.ts
@@ -362,6 +362,7 @@ export async function getSmartSuggestions(options: {
where: {
role,
status: 'ACTIVE',
+ isTest: false,
},
select: {
id: true,
@@ -683,6 +684,7 @@ export async function getMentorSuggestionsForProject(
where: {
role: 'MENTOR',
status: 'ACTIVE',
+ isTest: false,
},
select: {
id: true,
diff --git a/src/server/services/test-environment.ts b/src/server/services/test-environment.ts
new file mode 100644
index 0000000..1927bb1
--- /dev/null
+++ b/src/server/services/test-environment.ts
@@ -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 {
+ // 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 {
+ // 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' } })
+}
diff --git a/src/server/utils/ai-usage.ts b/src/server/utils/ai-usage.ts
index 55d975a..5e33e0d 100644
--- a/src/server/utils/ai-usage.ts
+++ b/src/server/utils/ai-usage.ts
@@ -29,6 +29,7 @@ export interface LogAIUsageInput {
entityType?: string
entityId?: string
model: string
+ provider?: string
promptTokens: number
completionTokens: number
totalTokens: number
@@ -98,6 +99,13 @@ const MODEL_PRICING: Record = {
// o4 reasoning models (future-proofing)
'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)
@@ -150,6 +158,16 @@ function getModelPricing(model: string): ModelPricing {
if (modelLower.startsWith('o4')) {
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
}
@@ -200,6 +218,7 @@ export async function logAIUsage(input: LogAIUsageInput): Promise {
entityType: input.entityType,
entityId: input.entityId,
model: input.model,
+ provider: input.provider,
promptTokens: input.promptTokens,
completionTokens: input.completionTokens,
totalTokens: input.totalTokens,