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

@@ -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<string, unknown> = {}
const where: Record<string, unknown> = { 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 },
})
: []