import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { and, eq, inArray, isNull } from "drizzle-orm"; import { authTenantUsers, authProfiles, authRoles, authUserRoles, authUsers, filetags, folders, tenants, } from "../../db/schema"; import { generateRandomPassword, hashPassword } from "../utils/password"; export default async function adminRoutes(server: FastifyInstance) { const deriveNameFromEmail = (email: string) => { const localPart = email.split("@")[0] || "Benutzer"; const normalized = localPart.replace(/[._-]+/g, " ").trim(); const parts = normalized.split(/\s+/).filter(Boolean); const firstName = parts[0] ? parts[0].charAt(0).toUpperCase() + parts[0].slice(1) : "Neuer"; const lastName = parts.length > 1 ? parts.slice(1).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ") : "Benutzer"; return { first_name: firstName, last_name: lastName }; }; const createTenantSeeds = async (tenantId: number, createdBy: string) => { const currentYear = new Date().getFullYear(); const timestamp = new Date(); const insertedTags = await server.db .insert(filetags) .values([ { tenant: tenantId, name: "Rechnungen", color: "#16a34a", createdDocumentType: "invoices", }, { tenant: tenantId, name: "Angebote", color: "#2563eb", createdDocumentType: "quotes", }, { tenant: tenantId, name: "Auftragsbestätigungen", color: "#7c3aed", createdDocumentType: "confirmationOrders", }, { tenant: tenantId, name: "Lieferscheine", color: "#ea580c", createdDocumentType: "deliveryNotes", }, { tenant: tenantId, name: "Eingangsrechnungen", color: "#dc2626", incomingDocumentType: "invoices", }, { tenant: tenantId, name: "Mahnungen", color: "#b91c1c", incomingDocumentType: "reminders", }, ]) .returning({ id: filetags.id, name: filetags.name, createdDocumentType: filetags.createdDocumentType, incomingDocumentType: filetags.incomingDocumentType, }); const invoiceTag = insertedTags.find((tag) => tag.createdDocumentType === "invoices"); const quoteTag = insertedTags.find((tag) => tag.createdDocumentType === "quotes"); const confirmationTag = insertedTags.find((tag) => tag.createdDocumentType === "confirmationOrders"); const deliveryTag = insertedTags.find((tag) => tag.createdDocumentType === "deliveryNotes"); const incomingInvoiceTag = insertedTags.find((tag) => tag.incomingDocumentType === "invoices"); const insertedFolders = await server.db .insert(folders) .values([ { tenant: tenantId, name: "Ausgangsrechnungen", function: "yearSubCategory", icon: "i-heroicons-document-text", isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, { tenant: tenantId, name: "Angebote", function: "yearSubCategory", icon: "i-heroicons-document-duplicate", isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, { tenant: tenantId, name: "Auftragsbestätigungen", function: "yearSubCategory", icon: "i-heroicons-clipboard-document-check", isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, { tenant: tenantId, name: "Lieferscheine", function: "yearSubCategory", icon: "i-heroicons-truck", isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, { tenant: tenantId, name: "Eingangsrechnungen", function: "yearSubCategory", icon: "i-heroicons-inbox-arrow-down", isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, { tenant: tenantId, name: "Belege Bankeinzahlung", function: "yearSubCategory", icon: "i-heroicons-banknotes", isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, ]) .returning({ id: folders.id, name: folders.name, }); const folderByName = new Map(insertedFolders.map((folder) => [folder.name, folder.id])); await server.db .insert(folders) .values([ { tenant: tenantId, name: String(currentYear), parent: folderByName.get("Ausgangsrechnungen"), function: "invoices", year: currentYear, icon: "i-heroicons-document-text", standardFiletype: invoiceTag?.id, standardFiletypeIsOptional: false, isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, { tenant: tenantId, name: String(currentYear), parent: folderByName.get("Angebote"), function: "quotes", year: currentYear, icon: "i-heroicons-document-duplicate", standardFiletype: quoteTag?.id, standardFiletypeIsOptional: false, isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, { tenant: tenantId, name: String(currentYear), parent: folderByName.get("Auftragsbestätigungen"), function: "confirmationOrders", year: currentYear, icon: "i-heroicons-clipboard-document-check", standardFiletype: confirmationTag?.id, standardFiletypeIsOptional: false, isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, { tenant: tenantId, name: String(currentYear), parent: folderByName.get("Lieferscheine"), function: "deliveryNotes", year: currentYear, icon: "i-heroicons-truck", standardFiletype: deliveryTag?.id, standardFiletypeIsOptional: false, isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, { tenant: tenantId, name: String(currentYear), parent: folderByName.get("Eingangsrechnungen"), function: "incomingInvoices", year: currentYear, icon: "i-heroicons-inbox-arrow-down", standardFiletype: incomingInvoiceTag?.id, standardFiletypeIsOptional: false, isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, { tenant: tenantId, name: String(currentYear), parent: folderByName.get("Belege Bankeinzahlung"), function: "deposit", year: currentYear, icon: "i-heroicons-banknotes", isSystemUsed: true, updatedAt: timestamp, updatedBy: createdBy, }, ]); }; const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => { if (!req.user?.user_id) { reply.code(401).send({ error: "Unauthorized" }); return null; } const [currentUser] = await server.db .select({ id: authUsers.id, is_admin: authUsers.is_admin, }) .from(authUsers) .where(eq(authUsers.id, req.user.user_id)) .limit(1); if (!currentUser?.is_admin) { reply.code(403).send({ error: "Admin access required" }); return null; } return currentUser; }; // ------------------------------------------------------------- // GET /admin/overview // ------------------------------------------------------------- server.get("/admin/overview", async (req, reply) => { try { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([ server.db .select({ id: tenants.id, name: tenants.name, short: tenants.short, createdAt: tenants.createdAt, locked: tenants.locked, }) .from(tenants), server.db .select({ id: authUsers.id, email: authUsers.email, created_at: authUsers.created_at, multiTenant: authUsers.multiTenant, must_change_password: authUsers.must_change_password, is_admin: authUsers.is_admin, }) .from(authUsers), server.db .select({ id: authProfiles.id, user_id: authProfiles.user_id, tenant_id: authProfiles.tenant_id, first_name: authProfiles.first_name, last_name: authProfiles.last_name, full_name: authProfiles.full_name, email: authProfiles.email, active: authProfiles.active, }) .from(authProfiles), server.db .select() .from(authTenantUsers), server.db .select({ id: authRoles.id, name: authRoles.name, description: authRoles.description, tenant_id: authRoles.tenant_id, }) .from(authRoles), server.db .select({ user_id: authUserRoles.user_id, role_id: authUserRoles.role_id, tenant_id: authUserRoles.tenant_id, }) .from(authUserRoles), ]); const users = userRows.map((user) => { const profiles = profileRows.filter((profile) => profile.user_id === user.id); const memberships = membershipRows.filter((membership) => membership.user_id === user.id); const roleAssignments = roleAssignmentRows.filter((assignment) => assignment.user_id === user.id); const preferredProfile = profiles.find((profile) => profile.active) || profiles[0]; const fallbackName = deriveNameFromEmail(user.email); return { ...user, display_name: preferredProfile?.full_name || user.email, profile_defaults: { first_name: preferredProfile?.first_name || fallbackName.first_name, last_name: preferredProfile?.last_name || fallbackName.last_name, }, profiles, tenant_ids: memberships.map((membership) => membership.tenant_id), role_assignments: roleAssignments, }; }); const tenantsWithCounts = tenantRows.map((tenant) => ({ ...tenant, user_count: membershipRows.filter((membership) => membership.tenant_id === tenant.id).length, })); return { users, tenants: tenantsWithCounts, roles: roleRows, unassignedProfiles: profileRows.filter((profile) => !profile.user_id), memberships: membershipRows, roleAssignments: roleAssignmentRows, }; } catch (err) { console.error("ERROR /admin/overview:", err); return reply.code(500).send({ error: "Internal Server Error" }); } }); // ------------------------------------------------------------- // POST /admin/users // ------------------------------------------------------------- server.post("/admin/users", async (req, reply) => { try { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; const body = req.body as { email?: string; password?: string; is_admin?: boolean; multiTenant?: boolean; first_name?: string; last_name?: string; }; const email = body.email?.trim().toLowerCase(); if (!email) { return reply.code(400).send({ error: "email required" }); } const existingUsers = await server.db .select({ id: authUsers.id }) .from(authUsers) .where(eq(authUsers.email, email)) .limit(1); if (existingUsers.length) { return reply.code(409).send({ error: "User with this email already exists" }); } const initialPassword = body.password?.trim() || generateRandomPassword(14); const passwordHash = await hashPassword(initialPassword); const [createdUser] = await server.db .insert(authUsers) .values({ email, passwordHash, is_admin: Boolean(body.is_admin), multiTenant: typeof body.multiTenant === "boolean" ? body.multiTenant : true, must_change_password: true, updatedAt: new Date(), }) .returning({ id: authUsers.id, email: authUsers.email, created_at: authUsers.created_at, multiTenant: authUsers.multiTenant, must_change_password: authUsers.must_change_password, is_admin: authUsers.is_admin, }); return { user: createdUser, initialPassword, profile_defaults: { first_name: body.first_name?.trim() || deriveNameFromEmail(email).first_name, last_name: body.last_name?.trim() || deriveNameFromEmail(email).last_name, }, }; } catch (err) { console.error("ERROR /admin/users:", err); return reply.code(500).send({ error: "Internal Server Error" }); } }); // ------------------------------------------------------------- // POST /admin/tenants // ------------------------------------------------------------- server.post("/admin/tenants", async (req, reply) => { try { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; const body = req.body as { name?: string; short?: string; }; const name = body.name?.trim(); const short = body.short?.trim(); if (!name || !short) { return reply.code(400).send({ error: "name and short required" }); } const [createdTenant] = await server.db .insert(tenants) .values({ name, short, updatedAt: new Date(), updatedBy: currentUser.id, }) .returning({ id: tenants.id, name: tenants.name, short: tenants.short, createdAt: tenants.createdAt, locked: tenants.locked, }); await createTenantSeeds(createdTenant.id, currentUser.id); return { tenant: createdTenant }; } catch (err) { console.error("ERROR /admin/tenants:", err); return reply.code(500).send({ error: "Internal Server Error" }); } }); // ------------------------------------------------------------- // PUT /admin/users/:user_id // ------------------------------------------------------------- server.put("/admin/users/:user_id", async (req, reply) => { try { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; const { user_id } = req.params as { user_id: string }; const body = req.body as { email?: string; multiTenant?: boolean; must_change_password?: boolean; is_admin?: boolean; }; const updateData: Record = { updatedAt: new Date(), }; if (typeof body.email === "string") updateData.email = body.email.trim().toLowerCase(); if (typeof body.multiTenant === "boolean") updateData.multiTenant = body.multiTenant; if (typeof body.must_change_password === "boolean") updateData.must_change_password = body.must_change_password; if (typeof body.is_admin === "boolean") updateData.is_admin = body.is_admin; const [updatedUser] = await server.db .update(authUsers) .set(updateData) .where(eq(authUsers.id, user_id)) .returning({ id: authUsers.id, email: authUsers.email, created_at: authUsers.created_at, multiTenant: authUsers.multiTenant, must_change_password: authUsers.must_change_password, is_admin: authUsers.is_admin, }); if (!updatedUser) { return reply.code(404).send({ error: "User not found" }); } return { user: updatedUser }; } catch (err) { console.error("ERROR /admin/users/:user_id:", err); return reply.code(500).send({ error: "Internal Server Error" }); } }); // ------------------------------------------------------------- // PUT /admin/tenants/:tenant_id // ------------------------------------------------------------- server.put("/admin/tenants/:tenant_id", async (req, reply) => { try { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; const { tenant_id } = req.params as { tenant_id: string }; const body = req.body as { name?: string; short?: string; }; const updateData: Record = { updatedAt: new Date(), updatedBy: currentUser.id, }; if (typeof body.name === "string") updateData.name = body.name.trim(); if (typeof body.short === "string") updateData.short = body.short.trim(); const [updatedTenant] = await server.db .update(tenants) .set(updateData) .where(eq(tenants.id, Number(tenant_id))) .returning({ id: tenants.id, name: tenants.name, short: tenants.short, createdAt: tenants.createdAt, locked: tenants.locked, }); if (!updatedTenant) { return reply.code(404).send({ error: "Tenant not found" }); } return { tenant: updatedTenant }; } catch (err) { console.error("ERROR /admin/tenants/:tenant_id:", err); return reply.code(500).send({ error: "Internal Server Error" }); } }); // ------------------------------------------------------------- // PUT /admin/users/:user_id/access // ------------------------------------------------------------- server.put("/admin/users/:user_id/access", async (req, reply) => { try { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; const { user_id } = req.params as { user_id: string }; const body = req.body as { tenant_ids?: number[]; role_assignments?: { tenant_id: number; role_id: string }[]; profile_defaults?: { first_name?: string; last_name?: string }; profile_assignments?: { tenant_id: number; profile_id?: string | null }[]; }; const tenantIds = Array.from(new Set((body.tenant_ids || []).map((tenantId) => Number(tenantId)).filter(Boolean))); const requestedAssignments = (body.role_assignments || []) .map((assignment) => ({ tenant_id: Number(assignment.tenant_id), role_id: assignment.role_id, })) .filter((assignment) => assignment.tenant_id && assignment.role_id && tenantIds.includes(assignment.tenant_id)); const [targetUser] = await server.db .select({ id: authUsers.id, email: authUsers.email }) .from(authUsers) .where(eq(authUsers.id, user_id)) .limit(1); if (!targetUser) { return reply.code(404).send({ error: "User not found" }); } const availableRoles = requestedAssignments.length ? await server.db .select({ id: authRoles.id, tenant_id: authRoles.tenant_id, }) .from(authRoles) .where( inArray( authRoles.id, requestedAssignments.map((assignment) => assignment.role_id) ) ) : []; const validRoleIds = new Set( availableRoles .filter((role) => role.tenant_id === null || requestedAssignments.some((assignment) => assignment.role_id === role.id && assignment.tenant_id === role.tenant_id) ) .map((role) => role.id) ); const validAssignments = requestedAssignments.filter((assignment) => validRoleIds.has(assignment.role_id)); const existingMemberships = await server.db .select() .from(authTenantUsers) .where(eq(authTenantUsers.user_id, user_id)); const removedTenantIds = existingMemberships .map((membership) => membership.tenant_id) .filter((tenantId) => !tenantIds.includes(tenantId)); const existingUserProfiles = await server.db .select({ id: authProfiles.id, tenant_id: authProfiles.tenant_id, }) .from(authProfiles) .where(eq(authProfiles.user_id, user_id)); const unassignedProfiles = tenantIds.length ? await server.db .select({ id: authProfiles.id, tenant_id: authProfiles.tenant_id, }) .from(authProfiles) .where( and( inArray(authProfiles.tenant_id, tenantIds), isNull(authProfiles.user_id) ) ) : []; const fallbackName = deriveNameFromEmail(targetUser.email); const profileDefaults = { first_name: body.profile_defaults?.first_name?.trim() || fallbackName.first_name, last_name: body.profile_defaults?.last_name?.trim() || fallbackName.last_name, }; const requestedProfileAssignments = new Map( (body.profile_assignments || []) .filter((assignment) => assignment?.tenant_id && assignment.profile_id) .map((assignment) => [Number(assignment.tenant_id), String(assignment.profile_id)]) ); await server.db .delete(authUserRoles) .where(eq(authUserRoles.user_id, user_id)); await server.db .delete(authTenantUsers) .where(eq(authTenantUsers.user_id, user_id)); if (tenantIds.length) { await server.db .insert(authTenantUsers) .values( tenantIds.map((tenantId) => ({ tenant_id: tenantId, user_id, created_by: currentUser.id, })) ); } if (validAssignments.length) { await server.db .insert(authUserRoles) .values( validAssignments.map((assignment) => ({ user_id, tenant_id: assignment.tenant_id, role_id: assignment.role_id, created_by: currentUser.id, })) ); } if (removedTenantIds.length) { await server.db .update(authProfiles) .set({ user_id: null }) .where( and( eq(authProfiles.user_id, user_id), inArray(authProfiles.tenant_id, removedTenantIds) ) ); } const existingProfileTenantIds = new Set(existingUserProfiles.map((profile) => profile.tenant_id)); for (const tenantId of tenantIds) { if (existingProfileTenantIds.has(tenantId)) continue; const requestedProfileId = requestedProfileAssignments.get(tenantId); const freeProfile = requestedProfileId ? unassignedProfiles.find((profile) => profile.id === requestedProfileId && profile.tenant_id === tenantId) : null; if (freeProfile) { await server.db .update(authProfiles) .set({ user_id }) .where(eq(authProfiles.id, freeProfile.id)); continue; } await server.db .insert(authProfiles) .values({ user_id, tenant_id: tenantId, first_name: profileDefaults.first_name, last_name: profileDefaults.last_name, email: targetUser.email, active: true, }); } return { success: true, tenant_ids: tenantIds, role_assignments: validAssignments, }; } catch (err) { console.error("ERROR /admin/users/:user_id/access:", err); return reply.code(500).send({ error: "Internal Server Error" }); } }); // ------------------------------------------------------------- // POST /admin/add-user-to-tenant // ------------------------------------------------------------- server.post("/admin/add-user-to-tenant", async (req, reply) => { try { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; const body = req.body as { user_id: string; tenant_id: number; role?: string; mode?: "single" | "multi"; }; if (!body.user_id || !body.tenant_id) { return reply.code(400).send({ error: "user_id and tenant_id required" }); } const mode = body.mode ?? "multi"; // ---------------------------- // SINGLE MODE → alte Verknüpfungen löschen // ---------------------------- if (mode === "single") { await server.db .delete(authTenantUsers) .where(eq(authTenantUsers.user_id, body.user_id)); } // ---------------------------- // Neue Verknüpfung hinzufügen // ---------------------------- await server.db .insert(authTenantUsers) .values({ user_id: body.user_id, tenant_id: body.tenant_id, created_by: currentUser.id, }); return { success: true, mode }; } catch (err) { console.error("ERROR /admin/add-user-to-tenant:", err); return reply.code(500).send({ error: "Internal Server Error" }); } }); // ------------------------------------------------------------- // GET /admin/user-tenants/:user_id // ------------------------------------------------------------- server.get("/admin/user-tenants/:user_id", async (req, reply) => { try { const currentUser = await requireAdmin(req, reply); if (!currentUser) return; const { user_id } = req.params as { user_id: string }; if (!user_id) { return reply.code(400).send({ error: "user_id required" }); } // ---------------------------- // 1) User existiert? // ---------------------------- const [user] = await server.db .select() .from(authUsers) .where(eq(authUsers.id, user_id)) .limit(1); if (!user) { return reply.code(400).send({ error: "faulty user_id presented" }); } // ---------------------------- // 2) Tenants Join über auth_tenant_users // ---------------------------- const tenantRecords = await server.db .select({ id: tenants.id, name: tenants.name, short: tenants.short, locked: tenants.locked, numberRanges: tenants.numberRanges, accountChart: tenants.accountChart, extraModules: tenants.extraModules, }) .from(authTenantUsers) .innerJoin( tenants, eq(authTenantUsers.tenant_id, tenants.id) ) .where(eq(authTenantUsers.user_id, user_id)); return { user_id, tenants: tenantRecords, }; } catch (err) { console.error("ERROR /admin/user-tenants:", err); return reply.code(500).send({ error: "Internal Server Error" }); } }); }