KI-AGENT: Erstzugriff und Mandanten-Grunddaten für Selfhosting ergänzen

This commit is contained in:
2026-05-18 18:17:23 +02:00
parent f33ccf730a
commit 4d24e3a657
7 changed files with 521 additions and 3 deletions

View File

@@ -47,6 +47,15 @@ OPENAI_API_KEY=replace-this
STIRLING_API_KEY=replace-this STIRLING_API_KEY=replace-this
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
# Optionaler Erststart-Bootstrap. Wenn gesetzt, werden Admin, Mandant,
# Admin-Rolle und Grunddaten beim Backend-Start idempotent angelegt.
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
FEDEO_BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
# FEDEO Matrix-Kommunikation # FEDEO Matrix-Kommunikation
# #
# Diese Werte werden von docker-compose.yml gelesen, wenn das Profil "matrix" # Diese Werte werden von docker-compose.yml gelesen, wenn das Profil "matrix"

View File

@@ -184,8 +184,17 @@ OPENAI_API_KEY=replace-this
STIRLING_API_KEY=replace-this STIRLING_API_KEY=replace-this
NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license NUXT_PUBLIC_PDF_LICENSE=replace-with-your-pdf-license
FEDEO_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
FEDEO_BOOTSTRAP_ADMIN_PASSWORD=change-this-admin-password
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME=Admin
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME=Benutzer
FEDEO_BOOTSTRAP_TENANT_NAME=Mein Unternehmen
FEDEO_BOOTSTRAP_TENANT_SHORT=MEIN
``` ```
Die `FEDEO_BOOTSTRAP_*`-Werte sind für den ersten Start gedacht. Wenn `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` gesetzt sind, legt das Backend idempotent einen Admin-Benutzer, einen ersten Mandanten, eine Administrator-Rolle und grundlegende Stammdaten an. Nach erfolgreichem Erstzugriff solltest du das Bootstrap-Passwort aus der `.env` entfernen oder ändern.
## Docker Compose mit optionaler S3-MinIO-Option ## Docker Compose mit optionaler S3-MinIO-Option
Die Selfhost-Konfiguration liegt in `docker-compose.selfhost.yml`. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen. Die Selfhost-Konfiguration liegt in `docker-compose.selfhost.yml`. Sie startet MinIO standardmäßig mit. Wenn du stattdessen AWS S3, Hetzner Object Storage, Backblaze B2 S3 oder einen anderen externen S3-Dienst nutzen willst, kannst du die Services `minio` und `createbuckets` entfernen und nur die entsprechenden S3-Umgebungsvariablen auf den externen Anbieter zeigen lassen.
@@ -414,6 +423,8 @@ Erwartung:
- Frontend liefert `200` oder `302` - Frontend liefert `200` oder `302`
- Backend liefert JSON wie `{"status":"ok"}` - Backend liefert JSON wie `{"status":"ok"}`
Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADMIN_EMAIL` und `FEDEO_BOOTSTRAP_ADMIN_PASSWORD` anmelden. Die Mandantensperre wird über `locked` gesteuert; `hasActiveLicense` wird nicht mehr für den Selfhost-Zugriff ausgewertet.
## Updates ## Updates
Bei neuen Versionen: Bei neuen Versionen:

View File

@@ -56,6 +56,7 @@ import {sendMail} from "./utils/mailer";
import {loadSecrets, secrets} from "./utils/secrets"; import {loadSecrets, secrets} from "./utils/secrets";
import {initMailer} from "./utils/mailer" import {initMailer} from "./utils/mailer"
import {initS3} from "./utils/s3"; import {initS3} from "./utils/s3";
import { runBootstrap } from "./modules/bootstrap.service";
//Services //Services
@@ -80,6 +81,7 @@ async function main() {
await app.register(dayjsPlugin); await app.register(dayjsPlugin);
await app.register(dbPlugin); await app.register(dbPlugin);
await app.register(servicesPlugin); await app.register(servicesPlugin);
await runBootstrap(app);
app.addHook('preHandler', (req, reply, done) => { app.addHook('preHandler', (req, reply, done) => {
console.log(req.method) console.log(req.method)

View File

@@ -0,0 +1,490 @@
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" },
{ name: "Angebote", color: "#2563eb", createdDocumentType: "quotes" },
{ name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders" },
{ name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes" },
{ name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices" },
{ name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders" },
]
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: "<p>vielen Dank für Ihre Anfrage.</p>" },
{ name: "Standard Schluss", pos: "endText" as const, text: "<p>Mit freundlichen Grüßen</p>" },
]
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")
}

View File

@@ -14,6 +14,7 @@ import {
} from "../../db/schema"; } from "../../db/schema";
import { generateRandomPassword, hashPassword } from "../utils/password"; import { generateRandomPassword, hashPassword } from "../utils/password";
import { sendMail } from "../utils/mailer"; import { sendMail } from "../utils/mailer";
import { ensureTenantBaseData } from "../modules/bootstrap.service";
export default async function adminRoutes(server: FastifyInstance) { export default async function adminRoutes(server: FastifyInstance) {
const deriveNameFromEmail = (email: string) => { const deriveNameFromEmail = (email: string) => {
@@ -825,6 +826,7 @@ export default async function adminRoutes(server: FastifyInstance) {
}); });
await createTenantSeeds(createdTenant.id, currentUser.id); await createTenantSeeds(createdTenant.id, currentUser.id);
await ensureTenantBaseData(server, createdTenant.id, currentUser.id);
return { tenant: createdTenant }; return { tenant: createdTenant };
} catch (err) { } catch (err) {

View File

@@ -124,6 +124,12 @@ services:
DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD} DOKUBOX_IMAP_PASSWORD: ${DOKUBOX_IMAP_PASSWORD}
OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
STIRLING_API_KEY: ${STIRLING_API_KEY} STIRLING_API_KEY: ${STIRLING_API_KEY}
FEDEO_BOOTSTRAP_ADMIN_EMAIL: ${FEDEO_BOOTSTRAP_ADMIN_EMAIL:-}
FEDEO_BOOTSTRAP_ADMIN_PASSWORD: ${FEDEO_BOOTSTRAP_ADMIN_PASSWORD:-}
FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_FIRST_NAME:-Admin}
FEDEO_BOOTSTRAP_ADMIN_LAST_NAME: ${FEDEO_BOOTSTRAP_ADMIN_LAST_NAME:-Benutzer}
FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO}
FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO}
labels: labels:
- traefik.enable=true - traefik.enable=true
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`) - traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)

View File

@@ -14,9 +14,7 @@ const tenantInitials = computed(() => {
.join('') || 'M' .join('') || 'M'
}) })
const activeTenants = computed(() => const activeTenants = computed(() => auth.tenants)
auth.tenants.filter((tenant) => tenant.hasActiveLicense)
)
const tenantItems = computed(() => [ const tenantItems = computed(() => [
activeTenants.value.map((tenant) => ({ activeTenants.value.map((tenant) => ({