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. +

+ +
+
) } 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 ( +
+
+ +
+

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 ( +
+
+ +
+

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()} + > + + + )} 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

- +
+ + +
{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 Submission Window - -
-
- - handleCreateNameChange(e.target.value)} - /> -
- -
- - setCreateForm({ ...createForm, slug: e.target.value })} - /> -
- -
- - setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })} - /> -
- -
- - setCreateForm({ ...createForm, windowOpenAt: e.target.value })} - /> -
- -
- - setCreateForm({ ...createForm, windowCloseAt: e.target.value })} - /> -
- -
- - -
- - {createForm.deadlinePolicy === 'GRACE' && ( -
- - setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })} - /> -
- )} - -
- setCreateForm({ ...createForm, lockOnClose: checked })} - /> - -
- -
- - -
-
-
-
-
-
- - {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)} -
-
- -
- - - {isPending && ( - - )} - {isOpen && ( - - )} - {isClosed && ( - - )} -
-
-
- ) - })} -
- )} -
-
- - {/* Edit Dialog */} - !open && setEditingWindow(null)}> - - - Edit Submission Window - -
-
- - handleEditNameChange(e.target.value)} - /> -
- -
- - setEditForm({ ...editForm, slug: e.target.value })} - /> -
- -
- - setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })} - /> -
- -
- - setEditForm({ ...editForm, windowOpenAt: e.target.value })} - /> -
- -
- - setEditForm({ ...editForm, windowCloseAt: e.target.value })} - /> -
- -
- - -
- - {editForm.deadlinePolicy === 'GRACE' && ( -
- - setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })} - /> -
- )} - -
- setEditForm({ ...editForm, lockOnClose: checked })} - /> - -
- -
- - setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })} - /> -
- -
- - -
-
-
-
- - {/* 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. - - )} - - - - - - - - -
- ) -} \ 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 (
@@ -187,13 +213,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { OpenAI (API Key) + Anthropic (Claude API) LiteLLM Proxy (ChatGPT Subscription) {field.value === 'litellm' ? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription' - : 'Direct OpenAI API access using your API key'} + : field.value === 'anthropic' + ? 'Direct Anthropic API access using Claude models' + : 'Direct OpenAI API access using your API key'} @@ -211,37 +240,71 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { )} - ( - - {isLiteLLM ? 'API Key (Optional)' : 'API Key'} - - - - - {isLiteLLM - ? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.' - : 'Your OpenAI API key. Leave blank to keep the existing key.'} - - - - )} - /> + {isAnthropic && ( + + + + Anthropic Claude Mode — AI calls use the Anthropic Messages API. + Claude Opus models include extended thinking for deeper analysis. + JSON responses are validated with automatic retry. + + + )} + + {isAnthropic ? ( + ( + + Anthropic API Key + + + + + Your Anthropic API key. Leave blank to keep the existing key. + + + + )} + /> + ) : ( + ( + + {isLiteLLM ? 'API Key (Optional)' : 'OpenAI API Key'} + + + + + {isLiteLLM + ? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.' + : 'Your OpenAI API key. Leave blank to keep the existing key.'} + + + + )} + /> + )} ( - {isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'} + {isLiteLLM ? 'LiteLLM Proxy URL' : isAnthropic ? 'Anthropic Base URL (Optional)' : 'API Base URL (Optional)'} http://localhost:4000{' '} or your server address. + ) : isAnthropic ? ( + <> + Custom base URL for Anthropic API proxy or gateway. Leave blank for default Anthropic API. + ) : ( <> Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI. @@ -288,7 +355,42 @@ export function AISettingsForm({ settings }: AISettingsFormProps) { )}
- {isLiteLLM || modelsData?.manualEntry ? ( + {isAnthropic ? ( + // Anthropic: fetch models from server (hardcoded list) + modelsLoading ? ( + + ) : modelsData?.success && modelsData.models && modelsData.models.length > 0 ? ( + + ) : ( + 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 ( -
-
- -
-
-
- 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.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 */} +
+
+ + + Test Active + + + {competition.name} + +
+ +
+ + {/* 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) => ( + + ))} + {roleUsers.length > 3 && ( +

+ +{roleUsers.length - 3} more (switch via banner) +

+ )} +
+
+ ))} +
+
+ + {/* Tear down */} +
+ + + + + + + + + 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 + + + +
+
+
+
+ ) +} 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 */} + + + + + + {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 */} + +
+
+
+ ) +} 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,