Add Anthropic API, test environment, remove locale settings

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 17:20:48 +01:00
parent f42b452899
commit 3e70de3a5a
55 changed files with 1630 additions and 770 deletions

View File

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

View File

@@ -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 (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">Error Loading Awards</h1>
<p className="text-sm text-muted-foreground">
Could not load competition or awards data. Please try again.
</p>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="space-y-6">

View File

@@ -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 <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
};
if (isCompError || isSessionsError) {
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Go back">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">Error Loading Deliberations</h1>
<p className="text-sm text-muted-foreground">
Could not load competition or deliberation data. Please try again.
</p>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="space-y-6 p-4 sm:p-6">

View File

@@ -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() {
<span className="truncate">{round.juryGroup.name}</span>
</div>
)}
{/* Live Control link for LIVE_FINAL rounds */}
{round.roundType === 'LIVE_FINAL' && (
<Link
href={`/admin/competitions/${competitionId}/live/${round.id}` as Route}
onClick={(e) => e.stopPropagation()}
>
<Button size="sm" variant="outline" className="w-full text-xs gap-1.5">
<Radio className="h-3.5 w-3.5" />
Live Control
</Button>
</Link>
)}
</CardContent>
</Card>
</Link>

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
if (!editionId) {
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 },
})

View File

@@ -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
</p>
</div>
</div>
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/mentorship` as Route}>
<GraduationCap className="mr-2 h-4 w-4" />
Mentorship
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
</div>
{program.description && (

View File

@@ -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: {

View File

@@ -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)
}

View File

@@ -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,