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]>
274 lines
8.8 KiB
TypeScript
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);
|
|
});
|
|
}
|