KI-AGENT: Erstzugriff und Mandanten-Grunddaten für Selfhosting ergänzen
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
490
backend/src/modules/bootstrap.service.ts
Normal file
490
backend/src/modules/bootstrap.service.ts
Normal 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")
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user