import { FastifyInstance } from "fastify" import { and, eq } from "drizzle-orm" import bcrypt from "bcrypt" import { accounts, authProfiles, authRoles, authRolePermissions, authTenantUsers, authUserRoles, authUsers, branches, filetags, folders, productcategories, servicecategories, taxTypes, teams, tenants, texttemplates, units, } from "../../db/schema" const adminPermissions = [ "mcp.tokens.write", "staff.time.read_all", "masterdata.customers.read", "masterdata.vendors.read", "masterdata.contacts.read", "masterdata.products.read", "masterdata.services.read", "masterdata.cost_centres.read", "masterdata.branches.read", "masterdata.teams.read", "masterdata.vehicles.read", "masterdata.inventory_items.read", "masterdata.units.read", "accounting.outgoing_documents.read", "accounting.outgoing_documents.write", "accounting.accounts.read", "accounting.incoming_invoices.read", "accounting.incoming_invoices.write", "accounting.bank.read", "accounting.statement_allocations.read", "organisation.customers.read", "organisation.projects.read", "organisation.plants.read", "organisation.events.read", "organisation.tasks.read", "organisation.tasks.write", ] const defaultUnits = [ { name: "Stück", single: "Stück", multiple: "Stück", short: "Stk.", step: "1" }, { name: "Stunde", single: "Stunde", multiple: "Stunden", short: "Std.", step: "0.25" }, { name: "Pauschale", single: "Pauschale", multiple: "Pauschalen", short: "Psch.", step: "1" }, { name: "Meter", single: "Meter", multiple: "Meter", short: "m", step: "0.1" }, ] const defaultTaxTypes = [ { label: "Umsatzsteuer 19%", percentage: 19 }, { label: "Umsatzsteuer 7%", percentage: 7 }, { label: "Steuerfrei", percentage: 0 }, ] const defaultAccounts = [ { number: "8400", label: "Erlöse 19% USt", accountChart: "skr03" }, { number: "8300", label: "Erlöse 7% USt", accountChart: "skr03" }, { number: "1200", label: "Bank", accountChart: "skr03" }, { number: "1000", label: "Kasse", accountChart: "skr03" }, { number: "1400", label: "Forderungen aus Lieferungen und Leistungen", accountChart: "skr03" }, { number: "1600", label: "Verbindlichkeiten aus Lieferungen und Leistungen", accountChart: "skr03" }, ] async function ensureGlobalDefaults(server: FastifyInstance, userId: string) { for (const unit of defaultUnits) { const existing = await server.db.select({ id: units.id }).from(units).where(eq(units.name, unit.name)).limit(1) if (!existing.length) await server.db.insert(units).values(unit) } for (const taxType of defaultTaxTypes) { const existing = await server.db .select({ id: taxTypes.id }) .from(taxTypes) .where(eq(taxTypes.percentage, taxType.percentage)) .limit(1) if (!existing.length) { await server.db.insert(taxTypes).values({ ...taxType, updatedAt: new Date(), updatedBy: userId, }) } } for (const account of defaultAccounts) { const existing = await server.db .select({ id: accounts.id }) .from(accounts) .where(and(eq(accounts.accountChart, account.accountChart), eq(accounts.number, account.number))) .limit(1) if (!existing.length) { await server.db.insert(accounts).values({ ...account, description: "FEDEO Standardkonto", }) } } } async function ensureTenantFileDefaults(server: FastifyInstance, tenantId: number, userId: string) { const currentYear = new Date().getFullYear() const timestamp = new Date() const tagDefaults = [ { name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices", isSystemUsed: true }, { name: "Angebote", color: "#2563eb", createdDocumentType: "quotes", isSystemUsed: true }, { name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders", isSystemUsed: true }, { name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes", isSystemUsed: true }, { name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices", isSystemUsed: true }, { name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders", isSystemUsed: true }, ] for (const tag of tagDefaults) { const existing = await server.db .select({ id: filetags.id }) .from(filetags) .where(and(eq(filetags.tenant, tenantId), eq(filetags.name, tag.name))) .limit(1) if (!existing.length) { await server.db.insert(filetags).values({ tenant: tenantId, ...tag, }) } } const allTags = await server.db.select().from(filetags).where(eq(filetags.tenant, tenantId)) const tagByCreatedType = new Map(allTags.map((tag) => [tag.createdDocumentType, tag.id])) const tagByIncomingType = new Map(allTags.map((tag) => [tag.incomingDocumentType, tag.id])) const rootFolders = [ { name: "Ausgangsrechnungen", function: "yearSubCategory" as const, icon: "i-heroicons-document-text" }, { name: "Angebote", function: "yearSubCategory" as const, icon: "i-heroicons-document-duplicate" }, { name: "Auftragsbestätigungen", function: "yearSubCategory" as const, icon: "i-heroicons-clipboard-document-check" }, { name: "Lieferscheine", function: "yearSubCategory" as const, icon: "i-heroicons-truck" }, { name: "Eingangsrechnungen", function: "yearSubCategory" as const, icon: "i-heroicons-inbox-arrow-down" }, { name: "Belege Bankeinzahlung", function: "yearSubCategory" as const, icon: "i-heroicons-banknotes" }, ] for (const folder of rootFolders) { const existing = await server.db .select({ id: folders.id }) .from(folders) .where(and(eq(folders.tenant, tenantId), eq(folders.name, folder.name))) .limit(1) if (!existing.length) { await server.db.insert(folders).values({ tenant: tenantId, name: folder.name, function: folder.function, icon: folder.icon, isSystemUsed: true, updatedAt: timestamp, updatedBy: userId, }) } } const allFolders = await server.db.select().from(folders).where(eq(folders.tenant, tenantId)) const rootFolderByName = new Map(allFolders.filter((folder) => !folder.parent).map((folder) => [folder.name, folder.id])) const yearFolders = [ { parentName: "Ausgangsrechnungen", function: "invoices" as const, icon: "i-heroicons-document-text", standardFiletype: tagByCreatedType.get("invoices"), }, { parentName: "Angebote", function: "quotes" as const, icon: "i-heroicons-document-duplicate", standardFiletype: tagByCreatedType.get("quotes"), }, { parentName: "Auftragsbestätigungen", function: "confirmationOrders" as const, icon: "i-heroicons-clipboard-document-check", standardFiletype: tagByCreatedType.get("confirmationOrders"), }, { parentName: "Lieferscheine", function: "deliveryNotes" as const, icon: "i-heroicons-truck", standardFiletype: tagByCreatedType.get("deliveryNotes"), }, { parentName: "Eingangsrechnungen", function: "incomingInvoices" as const, icon: "i-heroicons-inbox-arrow-down", standardFiletype: tagByIncomingType.get("invoices"), }, { parentName: "Belege Bankeinzahlung", function: "deposit" as const, icon: "i-heroicons-banknotes", }, ] for (const folder of yearFolders) { const parent = rootFolderByName.get(folder.parentName) if (!parent) continue const existing = await server.db .select({ id: folders.id }) .from(folders) .where(and(eq(folders.tenant, tenantId), eq(folders.parent, parent), eq(folders.year, currentYear))) .limit(1) if (!existing.length) { await server.db.insert(folders).values({ tenant: tenantId, name: String(currentYear), parent, function: folder.function, year: currentYear, icon: folder.icon, standardFiletype: folder.standardFiletype, standardFiletypeIsOptional: false, isSystemUsed: true, updatedAt: timestamp, updatedBy: userId, }) } } } export async function ensureTenantBaseData(server: FastifyInstance, tenantId: number, adminUserId: string) { await ensureGlobalDefaults(server, adminUserId) await ensureTenantFileDefaults(server, tenantId, adminUserId) const [adminRole] = await server.db .select({ id: authRoles.id }) .from(authRoles) .where(and(eq(authRoles.tenant_id, tenantId), eq(authRoles.name, "Administrator"))) .limit(1) let adminRoleId = adminRole?.id if (!adminRoleId) { const [createdRole] = await server.db .insert(authRoles) .values({ name: "Administrator", description: "Vollzugriff für die Administration dieses Mandanten", tenant_id: tenantId, created_by: adminUserId, }) .returning({ id: authRoles.id }) adminRoleId = createdRole.id } for (const permission of adminPermissions) { const existing = await server.db .select() .from(authRolePermissions) .where(and(eq(authRolePermissions.role_id, adminRoleId), eq(authRolePermissions.permission, permission))) .limit(1) if (!existing.length) { await server.db.insert(authRolePermissions).values({ role_id: adminRoleId, permission, }) } } const membership = await server.db .select() .from(authTenantUsers) .where(and(eq(authTenantUsers.tenant_id, tenantId), eq(authTenantUsers.user_id, adminUserId))) .limit(1) if (!membership.length) { await server.db.insert(authTenantUsers).values({ tenant_id: tenantId, user_id: adminUserId, created_by: adminUserId, }) } const roleAssignment = await server.db .select() .from(authUserRoles) .where(and( eq(authUserRoles.tenant_id, tenantId), eq(authUserRoles.user_id, adminUserId), eq(authUserRoles.role_id, adminRoleId), )) .limit(1) if (!roleAssignment.length) { await server.db.insert(authUserRoles).values({ tenant_id: tenantId, user_id: adminUserId, role_id: adminRoleId, created_by: adminUserId, }) } const profile = await server.db .select() .from(authProfiles) .where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.user_id, adminUserId))) .limit(1) if (!profile.length) { await server.db.insert(authProfiles).values({ tenant_id: tenantId, user_id: adminUserId, first_name: process.env.FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME || "Admin", last_name: process.env.FEDEO_BOOTSTRAP_ADMIN_LAST_NAME || "Benutzer", email: process.env.FEDEO_BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase(), active: true, }) } const branch = await server.db .select({ id: branches.id }) .from(branches) .where(and(eq(branches.tenant, tenantId), eq(branches.name, "Hauptstandort"))) .limit(1) let branchId = branch[0]?.id if (!branchId) { const [createdBranch] = await server.db.insert(branches).values({ tenant: tenantId, name: "Hauptstandort", number: "001", description: "Standardstandort", updatedAt: new Date(), updatedBy: adminUserId, }).returning({ id: branches.id }) branchId = createdBranch.id } const team = await server.db .select({ id: teams.id }) .from(teams) .where(and(eq(teams.tenant, tenantId), eq(teams.name, "Standardteam"))) .limit(1) if (!team.length) { await server.db.insert(teams).values({ tenant: tenantId, name: "Standardteam", description: "Automatisch angelegtes Standardteam", branch: branchId, updatedAt: new Date(), updatedBy: adminUserId, }) } const defaultProductCategory = await server.db .select({ id: productcategories.id }) .from(productcategories) .where(and(eq(productcategories.tenant, tenantId), eq(productcategories.name, "Standard"))) .limit(1) if (!defaultProductCategory.length) { await server.db.insert(productcategories).values({ tenant: tenantId, name: "Standard", description: "Standardkategorie", updatedAt: new Date(), updatedBy: adminUserId, }) } const defaultServiceCategory = await server.db .select({ id: servicecategories.id }) .from(servicecategories) .where(and(eq(servicecategories.tenant, tenantId), eq(servicecategories.name, "Standard"))) .limit(1) if (!defaultServiceCategory.length) { await server.db.insert(servicecategories).values({ tenant: tenantId, name: "Standard", description: "Standardkategorie", updated_at: new Date(), updated_by: adminUserId, }) } const templateDefaults = [ { name: "Standard Einleitung", pos: "startText" as const, text: "

vielen Dank für Ihre Anfrage.

" }, { name: "Standard Schluss", pos: "endText" as const, text: "

Mit freundlichen Grüßen

" }, ] for (const template of templateDefaults) { const existing = await server.db .select({ id: texttemplates.id }) .from(texttemplates) .where(and(eq(texttemplates.tenant, tenantId), eq(texttemplates.name, template.name))) .limit(1) if (!existing.length) { await server.db.insert(texttemplates).values({ tenant: tenantId, name: template.name, text: template.text, pos: template.pos, default: true, updatedAt: new Date(), updatedBy: adminUserId, }) } } } export async function runBootstrap(server: FastifyInstance) { const email = process.env.FEDEO_BOOTSTRAP_ADMIN_EMAIL?.trim().toLowerCase() const password = process.env.FEDEO_BOOTSTRAP_ADMIN_PASSWORD if (!email && !password) return if (!email || !password) { throw new Error("FEDEO_BOOTSTRAP_ADMIN_EMAIL und FEDEO_BOOTSTRAP_ADMIN_PASSWORD müssen gemeinsam gesetzt sein") } const [existingUser] = await server.db .select() .from(authUsers) .where(eq(authUsers.email, email)) .limit(1) let adminUser = existingUser if (!adminUser) { const [createdUser] = await server.db.insert(authUsers).values({ email, passwordHash: await bcrypt.hash(password, 10), is_admin: true, multiTenant: true, must_change_password: false, updatedAt: new Date(), }).returning() adminUser = createdUser console.log(`✅ Bootstrap-Admin angelegt: ${email}`) } else if (!adminUser.is_admin) { const [updatedUser] = await server.db.update(authUsers).set({ is_admin: true, updatedAt: new Date(), }).where(eq(authUsers.id, adminUser.id)).returning() adminUser = updatedUser console.log(`✅ Bootstrap-Adminrechte gesetzt: ${email}`) } const tenantName = process.env.FEDEO_BOOTSTRAP_TENANT_NAME?.trim() || "FEDEO" const tenantShort = process.env.FEDEO_BOOTSTRAP_TENANT_SHORT?.trim() || "FEDEO" const [existingTenant] = await server.db .select() .from(tenants) .where(eq(tenants.short, tenantShort)) .limit(1) let tenant = existingTenant if (!tenant) { const [createdTenant] = await server.db.insert(tenants).values({ name: tenantName, short: tenantShort, updatedAt: new Date(), updatedBy: adminUser.id, }).returning() tenant = createdTenant console.log(`✅ Bootstrap-Mandant angelegt: ${tenant.name}`) } await ensureTenantBaseData(server, tenant.id, adminUser.id) console.log("✅ Bootstrap-Grunddaten geprüft") }