import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as argon2 from 'argon2'; import { GroupsService } from '../groups/groups.service'; import { PrismaService } from '../prisma/prisma.service'; /** * Creates the initial Super-Admin account from Docker ENV variables on first boot. * Per D-05, D-07, D-13. * * onApplicationBootstrap läuft in genau zwei sequenziellen await-Schritten: * erst seedAdmin(), danach ensureDefaultGroupsForAllTenants(). Diese * sequenzielle Reihenfolge — nicht Nests Hook-Reihenfolge über Module * hinweg — ist der Ordering-Garant. Die Reparatur sitzt bewusst NICHT als * eigener onApplicationBootstrap-Hook in GroupsModule: GroupsModule ist * eine Dependency von UserModule, seine eigenen Hooks liefen deshalb VOR * dem Admin-Seed und würden auf einer frischen Installation den noch gar * nicht existierenden Default-Mandanten übergehen — dieselbe Falle, die * tenders/tender-scheduler.service.ts für den DÖE-Poll-Config-Seed * ausführlich dokumentiert (onModuleInit-Reihenfolge zwischen Modulen ist * unspezifiziert, onApplicationBootstrap läuft garantiert nach jedem * onModuleInit). seedAdmin() bleibt bewusst UNgekapselt: schlägt der * Admin-Seed fehl, soll der Start weiterhin laut scheitern — bestehendes * Verhalten, hier nicht aufgeweicht. */ @Injectable() export class AdminSeedService implements OnApplicationBootstrap { private readonly logger = new Logger(AdminSeedService.name); constructor( private prisma: PrismaService, private configService: ConfigService, private readonly groupsService: GroupsService, ) {} async onApplicationBootstrap() { await this.seedAdmin(); await this.ensureDefaultGroupsForAllTenants(); } private async seedAdmin() { const username = this.configService .get('TESSERA_ADMIN_USER') ?.toLowerCase(); const email = this.configService.get('TESSERA_ADMIN_EMAIL'); const password = this.configService.get('TESSERA_ADMIN_PASSWORD'); const forceChange = this.configService.get('TESSERA_FORCE_CHANGE') === 'true'; if (!username || !email || !password) { this.logger.log( 'Admin seed skipped: TESSERA_ADMIN_USER, TESSERA_ADMIN_EMAIL, or TESSERA_ADMIN_PASSWORD not set', ); return; } // Check if admin already exists const exists = await this.prisma.user.findUnique({ where: { username }, }); if (exists) { this.logger.log(`Admin user "${username}" already exists, skipping seed`); return; } // Upsert default tenant const tenant = await this.prisma.tenant.upsert({ where: { slug: 'default' }, update: {}, create: { name: 'Default', slug: 'default' }, }); // Ensure the default group exists BEFORE the Super-Admin user is // created, so user.create's addUserToDefaultGroup path (D-11/D-12) // finds a marked default group to join instead of relying on the // repair below to backfill the membership afterwards. await this.groupsService.ensureDefaultGroup(tenant.id); // Create Super-Admin user const passwordHash = await argon2.hash(password); await this.prisma.user.create({ data: { username, email, passwordHash, role: 'SUPER_ADMIN', tenantId: tenant.id, mustChangePassword: forceChange, isActive: true, }, }); this.logger.log( `Admin user "${username}" seeded as SUPER_ADMIN in tenant "${tenant.slug}"`, ); } /** * One-time startup repair for installations that already exist without a * default group — e.g. a fresh database where migration * 20260804130130_add_groups_and_module_grants' backfill ran before any * tenant existed, or an install whose admin already exists so seedAdmin() * returned early without ever touching a tenant. Runs per-tenant in its * own try/catch and is additionally wrapped as a whole, so a failing * tenant.findMany or a single tenant's ensureDefaultGroup call can never * block the API from starting. */ private async ensureDefaultGroupsForAllTenants() { try { const tenants = await this.prisma.tenant.findMany({ select: { id: true, slug: true }, }); let repaired = 0; for (const tenant of tenants) { try { const group = await this.groupsService.ensureDefaultGroup(tenant.id); if (group !== null) { repaired += 1; } } catch (err) { this.logger.error( `Default-group repair failed for tenant "${tenant.slug}": ${ err instanceof Error ? err.message : String(err) }`, ); } } if (repaired > 0) { this.logger.warn( `Default-group repair created a default group for ${repaired} tenant(s) that had none`, ); } } catch (err) { this.logger.error( `Default-group repair could not load tenants: ${ err instanceof Error ? err.message : String(err) }`, ); } } }