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

@@ -84,7 +84,9 @@ export const projectRouter = router({
const skip = (page - 1) * perPage
// Build where clause
const where: Record<string, unknown> = {}
const where: Record<string, unknown> = {
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<string, unknown> = {}
const where: Record<string, unknown> = {
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<string, unknown> = {}
const where: Record<string, unknown> = {
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<string, unknown> = {
programId,
isTest: false,
projectRoundStates: { none: {} }, // Projects not assigned to any round
}