Kundenportal arbeiten

This commit is contained in:
2026-04-08 18:52:04 +02:00
parent d9e5df07bf
commit f125617af0
9 changed files with 1017 additions and 23 deletions

View File

@@ -4,6 +4,7 @@ import { and, eq, inArray, isNull } from "drizzle-orm";
import {
authTenantUsers,
authProfiles,
customers,
authRoles,
authUserRoles,
authUsers,
@@ -12,6 +13,7 @@ import {
tenants,
} from "../../db/schema";
import { generateRandomPassword, hashPassword } from "../utils/password";
import { sendMail } from "../utils/mailer";
export default async function adminRoutes(server: FastifyInstance) {
const deriveNameFromEmail = (email: string) => {
@@ -255,6 +257,33 @@ export default async function adminRoutes(server: FastifyInstance) {
return currentUser;
};
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
const existingRoles = await server.db
.select({
id: authRoles.id,
name: authRoles.name,
})
.from(authRoles)
.where(eq(authRoles.tenant_id, tenantId));
const portalRole = existingRoles.find((role) => role.name === "Kundenportal");
if (portalRole) return portalRole.id;
const [createdRole] = await server.db
.insert(authRoles)
.values({
name: "Kundenportal",
description: "Automatisch angelegte Rolle für eingeladene Kundenportal-Benutzer",
tenant_id: tenantId,
created_by: createdBy,
})
.returning({
id: authRoles.id,
});
return createdRole.id;
};
// -------------------------------------------------------------
// GET /admin/overview
// -------------------------------------------------------------
@@ -422,6 +451,233 @@ export default async function adminRoutes(server: FastifyInstance) {
}
});
server.post("/admin/customers/:customerId/invite-portal-user", async (req, reply) => {
try {
const currentUser = await requireAdmin(req, reply);
if (!currentUser) return;
const tenantId = Number(req.user?.tenant_id);
const { customerId } = req.params as { customerId: string };
if (!tenantId) {
return reply.code(400).send({ error: "No tenant selected" });
}
const [tenantRecord] = await server.db
.select({
id: tenants.id,
name: tenants.name,
portalDomain: tenants.portalDomain,
})
.from(tenants)
.where(eq(tenants.id, tenantId))
.limit(1);
const [customerRecord] = await server.db
.select()
.from(customers)
.where(and(eq(customers.id, Number(customerId)), eq(customers.tenant, tenantId)))
.limit(1);
if (!customerRecord) {
return reply.code(404).send({ error: "Customer not found" });
}
const customerInfo = customerRecord.infoData && typeof customerRecord.infoData === "object" ? customerRecord.infoData as Record<string, any> : {};
const email = String(customerInfo.email || customerInfo.invoiceEmail || "").trim().toLowerCase();
if (!email) {
return reply.code(400).send({ error: "Customer has no email address" });
}
const generatedPassword = generateRandomPassword(14);
const passwordHash = await hashPassword(generatedPassword);
const [existingUser] = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
is_admin: authUsers.is_admin,
})
.from(authUsers)
.where(eq(authUsers.email, email))
.limit(1);
const derivedName = deriveNameFromEmail(email);
const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
let userId = existingUser?.id || null;
let createdNewUser = false;
if (existingUser) {
const [existingProfile] = await server.db
.select({
id: authProfiles.id,
customer_for_portal: authProfiles.customer_for_portal,
})
.from(authProfiles)
.where(and(
eq(authProfiles.user_id, existingUser.id),
eq(authProfiles.tenant_id, tenantId)
))
.limit(1);
if (existingUser.is_admin) {
return reply.code(409).send({ error: "Email address is already used by an admin user" });
}
if (!existingProfile) {
return reply.code(409).send({ error: "Email address is already used by another user" });
}
if (existingProfile.customer_for_portal && existingProfile.customer_for_portal !== customerRecord.id) {
return reply.code(409).send({ error: "Email address is already assigned to another portal customer" });
}
await server.db
.update(authUsers)
.set({
passwordHash,
must_change_password: true,
multiTenant: false,
updatedAt: new Date(),
})
.where(eq(authUsers.id, existingUser.id));
userId = existingUser.id;
} else {
const [createdUser] = await server.db
.insert(authUsers)
.values({
email,
passwordHash,
is_admin: false,
multiTenant: false,
must_change_password: true,
updatedAt: new Date(),
})
.returning({
id: authUsers.id,
});
userId = createdUser.id;
createdNewUser = true;
}
const portalRoleId = await ensurePortalRoleForTenant(tenantId, currentUser.id);
const existingMemberships = await server.db
.select()
.from(authTenantUsers)
.where(and(
eq(authTenantUsers.user_id, userId!),
eq(authTenantUsers.tenant_id, tenantId)
))
.limit(1);
if (!existingMemberships.length) {
await server.db
.insert(authTenantUsers)
.values({
tenant_id: tenantId,
user_id: userId!,
created_by: currentUser.id,
});
}
const existingPortalRoleAssignment = await server.db
.select()
.from(authUserRoles)
.where(and(
eq(authUserRoles.user_id, userId!),
eq(authUserRoles.tenant_id, tenantId),
eq(authUserRoles.role_id, portalRoleId)
))
.limit(1);
if (!existingPortalRoleAssignment.length) {
await server.db
.insert(authUserRoles)
.values({
user_id: userId!,
tenant_id: tenantId,
role_id: portalRoleId,
created_by: currentUser.id,
});
}
const [existingTenantProfile] = await server.db
.select({
id: authProfiles.id,
user_id: authProfiles.user_id,
customer_for_portal: authProfiles.customer_for_portal,
})
.from(authProfiles)
.where(and(
eq(authProfiles.user_id, userId!),
eq(authProfiles.tenant_id, tenantId)
))
.limit(1);
if (existingTenantProfile) {
await server.db
.update(authProfiles)
.set({
first_name: firstName,
last_name: lastName,
email,
customer_for_portal: customerRecord.id,
active: true,
})
.where(eq(authProfiles.id, existingTenantProfile.id));
} else {
await server.db
.insert(authProfiles)
.values({
user_id: userId!,
tenant_id: tenantId,
first_name: firstName,
last_name: lastName,
email,
customer_for_portal: customerRecord.id,
active: true,
});
}
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
const mailResult = await sendMail(
email,
`FEDEO | Einladung ins Kundenportal`,
`
<p>Hallo${customerRecord.name ? ` ${customerRecord.name}` : ""},</p>
<p>für Sie wurde ein Zugang zum FEDEO Kundenportal eingerichtet.</p>
<p><strong>E-Mail:</strong> ${email}</p>
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""}
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
`
);
if (!mailResult.success) {
return reply.code(500).send({ error: "Invitation email could not be sent" });
}
return {
success: true,
createdNewUser,
email,
initialPassword: generatedPassword,
portalUrl,
};
} catch (err) {
console.error("ERROR /admin/customers/:customerId/invite-portal-user:", err);
return reply.code(500).send({ error: "Internal Server Error" });
}
});
// -------------------------------------------------------------
// POST /admin/tenants
// -------------------------------------------------------------

View File

@@ -10,7 +10,9 @@ import { secrets } from "../utils/secrets"
import { saveFile } from "../utils/files"
import { eq, inArray } from "drizzle-orm"
import { and } from "drizzle-orm"
import {
authProfiles,
files,
createddocuments,
customers
@@ -18,6 +20,55 @@ import {
export default async function fileRoutes(server: FastifyInstance) {
const getPortalCustomerId = async (req: any) => {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
const loadSingleFileForRequest = async (req: any, id: string) => {
const tenantId = req.user?.tenant_id
if (!tenantId) return null
const portalCustomerId = await getPortalCustomerId(req)
if (!portalCustomerId) {
const rows = await server.db
.select()
.from(files)
.where(and(eq(files.id, id), eq(files.tenant, tenantId)))
return rows[0] || null
}
const rows = await server.db
.select({
file: files,
})
.from(files)
.leftJoin(createddocuments, eq(files.createddocument, createddocuments.id))
.where(and(
eq(files.id, id),
eq(files.tenant, tenantId),
eq(createddocuments.customer, portalCustomerId),
eq(createddocuments.availableInPortal, true)
))
.limit(1)
return rows[0]?.file || null
}
// -------------------------------------------------------------
// MULTIPART INIT
@@ -80,12 +131,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// 🔹 EINZELNE DATEI
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "Not found" })
return file
@@ -135,12 +181,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// 1⃣ SINGLE DOWNLOAD
// -------------------------------------------------
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "File not found" })
const command = new GetObjectCommand({
@@ -217,12 +258,7 @@ export default async function fileRoutes(server: FastifyInstance) {
// SINGLE FILE PRESIGNED URL
// -------------------------------------------------
if (id) {
const rows = await server.db
.select()
.from(files)
.where(eq(files.id, id))
const file = rows[0]
const file = await loadSingleFileForRequest(req, id)
if (!file) return reply.code(404).send({ error: "Not found" })
const url = await getSignedUrl(

View File

@@ -11,6 +11,7 @@ import {
sql,
} from "drizzle-orm"
import { authProfiles } from "../../../db/schema";
import { resourceConfig } from "../../utils/resource.config";
import { useNextNumberRangeNumber } from "../../utils/functions";
import { getHistoryEntityLabel, insertHistoryItem } from "../../utils/history";
@@ -18,6 +19,9 @@ import { diffObjects } from "../../utils/diff";
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service";
import { decrypt, encrypt } from "../../utils/crypt";
const PORTAL_ALLOWED_RESOURCES = new Set(["customers", "contracts", "createddocuments"])
const PORTAL_VISIBLE_DOCUMENT_TYPES = ["invoices", "advanceInvoices", "cancellationInvoices"]
// -------------------------------------------------------------
// SQL Suche auf mehreren Feldern (Haupttabelle + Relationen)
// -------------------------------------------------------------
@@ -130,6 +134,65 @@ function applyResourceWhereFilters(resource: string, table: any, whereCond: any)
return whereCond
}
async function getPortalCustomerId(server: FastifyInstance, req: any) {
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
if (!tenantId || !userId) return null
const [profile] = await server.db
.select({ customer_for_portal: authProfiles.customer_for_portal })
.from(authProfiles)
.where(and(
eq(authProfiles.tenant_id, tenantId),
eq(authProfiles.user_id, userId)
))
.limit(1)
return profile?.customer_for_portal || null
}
function applyPortalScope(resource: string, table: any, whereCond: any, portalCustomerId: number | null) {
if (!portalCustomerId) return whereCond
if (!PORTAL_ALLOWED_RESOURCES.has(resource)) {
return null
}
if (resource === "customers") {
return and(whereCond, eq(table.id, portalCustomerId))
}
if (resource === "contracts") {
return and(whereCond, eq(table.customer, portalCustomerId))
}
if (resource === "createddocuments") {
return and(
whereCond,
eq(table.customer, portalCustomerId),
eq(table.availableInPortal, true),
inArray(table.type, PORTAL_VISIBLE_DOCUMENT_TYPES)
)
}
return whereCond
}
function sanitizePortalCustomerUpdate(payload: Record<string, any>) {
const nextInfoData = payload.infoData && typeof payload.infoData === "object" ? payload.infoData : {}
return {
name: payload.name,
firstname: payload.firstname,
lastname: payload.lastname,
salutation: payload.salutation,
title: payload.title,
nameAddition: payload.nameAddition,
infoData: nextInfoData,
}
}
function getTenantColumn(resource: string, table: any) {
const config = resourceConfig[resource]
const tenantKey = config?.tenantKey || "tenant"
@@ -271,11 +334,16 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!config) {
return reply.code(404).send({ error: "Unknown resource" })
}
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = config.table
const tenantColumn = getTenantColumn(resource, table)
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
let q = server.db.select().from(table).$dynamic()
const searchCols: any[] = (config.searchColumns || []).map(c => table[c])
@@ -380,6 +448,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!config) {
return reply.code(404).send({ error: "Unknown resource" });
}
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = config.table;
const { queryConfig } = req;
@@ -389,6 +461,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const tenantColumn = getTenantColumn(resource, table);
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
const parsedFilters: Array<{ key: string; value: any }> = []
@@ -489,6 +562,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
}
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
if (search) {
const searchCond = buildSearchCondition(searchCols, search.trim())
@@ -570,10 +644,15 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (!tenantId) return reply.code(400).send({ error: "No tenant selected" })
const { resource, no_relations } = req.params as { resource: string, no_relations?: boolean }
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId && !PORTAL_ALLOWED_RESOURCES.has(resource)) {
return reply.code(403).send({ error: "Forbidden" })
}
const table = resourceConfig[resource].table
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const projRows = await server.db
.select()
@@ -624,6 +703,10 @@ export default async function resourceRoutes(server: FastifyInstance) {
try {
if (!req.user?.tenant_id) return reply.code(400).send({ error: "No tenant selected" });
const { resource } = req.params as { resource: string };
const portalCustomerId = await getPortalCustomerId(server, req)
if (portalCustomerId) {
return reply.code(403).send({ error: "Forbidden" })
}
if (resource === "accounts") {
return reply.code(403).send({ error: "Accounts are read-only" })
}
@@ -703,8 +786,12 @@ export default async function resourceRoutes(server: FastifyInstance) {
const body = req.body as Record<string, any>
const tenantId = req.user?.tenant_id
const userId = req.user?.user_id
const portalCustomerId = await getPortalCustomerId(server, req)
if (!tenantId || !userId) return reply.code(401).send({ error: "Unauthorized" })
if (portalCustomerId && resource !== "customers") {
return reply.code(403).send({ error: "Forbidden" })
}
const table = resourceConfig[resource].table
const normalizeDate = (val: any) => { const d = new Date(val); return isNaN(d.getTime()) ? null : d; }
@@ -712,13 +799,25 @@ export default async function resourceRoutes(server: FastifyInstance) {
const [oldRecord] = await server.db
.select()
.from(table)
.where(and(eq(table.id, id), eq(table.tenant, tenantId)))
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
.limit(1)
if (!oldRecord) {
return reply.code(404).send({ error: "Resource not found" })
}
let data: Record<string, any> = { ...body, updated_at: new Date().toISOString(), updated_by: userId }
//@ts-ignore
delete data.updatedBy; delete data.updatedAt;
if (portalCustomerId) {
data = {
...sanitizePortalCustomerUpdate(data),
updated_at: data.updated_at,
updated_by: data.updated_by,
}
}
if (resource === "members") {
data = normalizeMemberPayload(data)
const validationError = validateMemberPayload(data)