Files
ApiCore/ApiCore-Backend/prisma/seed.ts
T
Nick SzaunigandClaude Opus 5.5 ab45d6e24e fix: seed creates the first admin only on an empty user table
The seed checked only SEED_ADMIN_EMAIL. After the real admin changed
their address, every start created a second ADMIN with a random
password and wrote that password to the log. The seed now skips as
soon as any user exists and never prints an operator-supplied
password. seedAdmin() is exported for tests; main() runs only when the
file is executed directly.

Refs #45

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-16 09:12:04 +02:00

274 lines
8.8 KiB
TypeScript

import 'dotenv/config';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'node:crypto';
import { PrismaPg } from '@prisma/adapter-pg';
import { Prisma, PrismaClient } from '../generated/prisma';
const BCRYPT_SALT_ROUNDS = 12;
const DEFAULT_ADMIN_EMAIL = '[email protected]';
// No fixed fallback password: a fixed literal shipped in source would be a
// well-known, guessable credential for any deployment that forgets to set
// SEED_ADMIN_PASSWORD. A random one is generated instead and printed once
// below, same as an operator-supplied one would be.
function generateRandomPassword(): string {
return crypto.randomBytes(18).toString('base64url');
}
// Only printed the moment the account is actually created (first deploy on
// an empty user table), never on later restarts: by then the real admin may
// have changed the password via /profile, so repeating it would just be
// stale and would leak credentials into logs indefinitely for no reason.
// An operator-supplied password is never printed: the operator already has
// it, and the log would only be one more place it leaks to.
function printLoginBanner(
email: string,
password: string | null,
log: (line: string) => void,
): void {
const url = process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const line = '='.repeat(60);
log(line);
log('Seed: Admin-Zugang eingerichtet.');
log(` URL: ${url}`);
log(` Benutzer: ${email}`);
log(
password === null
? ' Passwort: wie in SEED_ADMIN_PASSWORD gesetzt'
: ` Passwort: ${password}`,
);
log(line);
}
// Duplicated from src/task-templates/recurrence.ts rather than imported:
// tsconfig.seed.json scopes this build to rootDir "./prisma" only (its own
// isolated compile, separate from the main Nest build), so a cross-rootDir
// import would fail to compile. Only the YEARLY case is needed here.
function nextYearlyOccurrenceUtc(
month: number,
dayOfMonth: number,
from: Date,
): Date {
const year = from.getUTCFullYear();
const clampedDay = (y: number): number =>
Math.min(dayOfMonth, new Date(Date.UTC(y, month, 0)).getUTCDate());
const today = new Date(
Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate()),
);
const candidate = new Date(Date.UTC(year, month - 1, clampedDay(year)));
if (candidate.getTime() >= today.getTime()) {
return candidate;
}
return new Date(Date.UTC(year + 1, month - 1, clampedDay(year + 1)));
}
interface DefaultTaskTemplate {
title: string;
description: string;
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT';
month: number;
dayOfMonth: number;
}
// The standard German-beekeeping annual cycle, one flagship task per month -
// gives a new deployment a ready-made starter calendar instead of a
// completely empty task-template list. Admins can edit/deactivate any of
// these afterward; this only ever runs once (see the count() guard below).
const DEFAULT_TASK_TEMPLATES: DefaultTaskTemplate[] = [
{
title: 'Wintersitz kontrollieren',
description:
'Ruhige Kontrolle von außen: Flugloch frei, Gewicht per Anheben schätzen, auf Mäuse-/Spechtschäden achten. Beute möglichst nicht öffnen.',
priority: 'NORMAL',
month: 1,
dayOfMonth: 15,
},
{
title: 'Gemülldiagnose',
description:
'Windel auswerten: natürlicher Varroa-Totenfall, Bienensitz und Lage der Wintertraube anhand des Gemülls einschätzen.',
priority: 'NORMAL',
month: 2,
dayOfMonth: 15,
},
{
title: 'Frühjahrsdurchsicht',
description:
'Erste Innendurchsicht bei ca. 15°C und ruhigem Wetter: Brutnest, Futtervorrat, Volksstärke und Weiselrichtigkeit prüfen.',
priority: 'HIGH',
month: 3,
dayOfMonth: 15,
},
{
title: 'Volk erweitern',
description:
'Zargen/Rähmchen nach Volksstärke erweitern, Baurahmen einhängen, Platz für die Frühtracht schaffen.',
priority: 'NORMAL',
month: 4,
dayOfMonth: 1,
},
{
title: 'Schwarmkontrolle beginnen',
description:
'Wöchentlich auf Weiselzellen kontrollieren, bis zum Ende der Schwarmzeit fortführen. Maßnahmen bei Bedarf: Ableger bilden, Zellen brechen.',
priority: 'HIGH',
month: 5,
dayOfMonth: 1,
},
{
title: 'Honigernte vorbereiten',
description:
'Reifegrad der Frühtracht prüfen (Spritzprobe), Schleuderplan und Gerätereinigung vorbereiten.',
priority: 'NORMAL',
month: 6,
dayOfMonth: 15,
},
{
title: 'Varroa-Kontrolle nach der Ernte',
description:
'Milbenbefall messen (z. B. Puderzuckermethode oder natürlicher Totenfall) und Behandlung planen, sobald die Honigräume abgeräumt sind.',
priority: 'HIGH',
month: 7,
dayOfMonth: 15,
},
{
title: 'Einfütterung beginnen',
description:
'Wintervorrat auffüttern (Zielgewicht je nach Beutensystem), Futteraufnahme nach ein bis zwei Wochen kontrollieren.',
priority: 'HIGH',
month: 8,
dayOfMonth: 1,
},
{
title: 'Varroa-Restentmilbung / Winterfestigkeit prüfen',
description:
'Zweite Varroabehandlung durchführen, Volksstärke und Weiselrichtigkeit für den Winter beurteilen.',
priority: 'HIGH',
month: 9,
dayOfMonth: 15,
},
{
title: 'Fütterung abschließen, Mäusegitter anbringen',
description:
'Letzte Futtergabe, Fluglochkeile bzw. Mäusegitter montieren, Bienenstand winterfest machen.',
priority: 'NORMAL',
month: 10,
dayOfMonth: 1,
},
{
title: 'Winterbehandlung (Oxalsäure)',
description:
'Brutfreiheit abwarten und Träufel- oder Sprühbehandlung gegen Varroa durchführen.',
priority: 'HIGH',
month: 11,
dayOfMonth: 15,
},
{
title: 'Jahresrückblick und Gerätewartung',
description:
'Saison auswerten, Zargen und Rähmchen reinigen/reparieren, Material für die kommende Saison planen.',
priority: 'LOW',
month: 12,
dayOfMonth: 15,
},
];
async function seedDefaultTaskTemplates(prisma: PrismaClient): Promise<void> {
const existingCount = await prisma.taskTemplate.count();
if (existingCount > 0) {
return;
}
const now = new Date();
await prisma.taskTemplate.createMany({
data: DEFAULT_TASK_TEMPLATES.map((template) => ({
title: template.title,
description: template.description,
priority: template.priority,
recurrenceType: 'YEARLY',
recurrenceMonth: template.month,
recurrenceDayOfMonth: template.dayOfMonth,
nextDueDate: nextYearlyOccurrenceUtc(
template.month,
template.dayOfMonth,
now,
),
})),
});
console.log(
`Seed: ${DEFAULT_TASK_TEMPLATES.length} Standard-Aufgabenvorlagen angelegt.`,
);
}
type AdminSeedClient = Pick<PrismaClient, 'user' | 'role'>;
/**
* Creates the first administrator, but only while the user table is still
* empty. Checking SEED_ADMIN_EMAIL instead would add a second ADMIN (and log
* its password) on every start after the real admin changed their address.
*/
export async function seedAdmin(
prisma: AdminSeedClient,
env: NodeJS.ProcessEnv,
log: (line: string) => void,
): Promise<'created' | 'skipped'> {
if ((await prisma.user.count()) > 0) {
log('Seed: Es gibt bereits Benutzer, kein Admin angelegt.');
return 'skipped';
}
const email = env.SEED_ADMIN_EMAIL ?? DEFAULT_ADMIN_EMAIL;
const suppliedPassword = env.SEED_ADMIN_PASSWORD;
const password = suppliedPassword ?? generateRandomPassword();
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const adminRole = await prisma.role.findUniqueOrThrow({
where: { key: 'ADMIN' },
});
try {
await prisma.user.create({
data: {
email,
passwordHash,
name: 'Administrator',
roleId: adminRole.id,
},
});
} catch (error: unknown) {
// P2002 = unique constraint violation on email: another replica's
// startup won this race between the count above and this create()
// call. The user now exists either way, so this is a no-op, not a
// failure - container startup must not crash on it.
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
log(`Seed: admin user ${email} was created concurrently, skipping.`);
return 'skipped';
}
throw error;
}
printLoginBanner(
email,
suppliedPassword === undefined ? password : null,
log,
);
return 'created';
}
async function main(): Promise<void> {
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
await seedDefaultTaskTemplates(prisma);
await seedAdmin(prisma, process.env, (line) => console.log(line));
await prisma.$disconnect();
}
// Only when run as `node dist/seed.js`, not when a test imports seedAdmin.
if (require.main === module) {
main().catch((error: unknown) => {
console.error(error);
process.exit(1);
});
}