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..d4cd587 --- /dev/null +++ b/prisma/migrations/20260221000000_add_test_isolation_anthropic_remove_locale/migration.sql @@ -0,0 +1,8 @@ +-- Add isTest field to User, Program, Project, Competition for test environment isolation +ALTER TABLE "User" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Program" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Project" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Competition" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false; + +-- Index for efficient test data filtering +CREATE INDEX "Competition_isTest_idx" ON "Competition"("isTest"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4c3a20f..dc18965 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -350,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? @@ -494,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 @@ -618,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 @@ -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 ( )} + {isSuperAdmin && ( + + + Test Env + + )}
@@ -319,6 +327,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin Webhooks + + + + Test Env + +
)} @@ -513,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 */} 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/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/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' } }) +}