first commit

This commit is contained in:
Stefan Hacker
2026-01-29 01:16:54 +01:00
commit 31f807fbd0
12106 changed files with 2480685 additions and 0 deletions
@@ -0,0 +1,66 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// Default settings
const DEFAULT_SETTINGS: Record<string, string> = {
customerSupportTicketsEnabled: 'false',
// Vertrags-Cockpit: Fristenschwellen (in Tagen)
deadlineCriticalDays: '14', // Rot: Kritisch
deadlineWarningDays: '42', // Gelb: Warnung (6 Wochen)
deadlineOkDays: '90', // Grün: OK (3 Monate)
};
export async function getSetting(key: string): Promise<string | null> {
const setting = await prisma.appSetting.findUnique({
where: { key },
});
if (setting) {
return setting.value;
}
// Return default if exists
return DEFAULT_SETTINGS[key] ?? null;
}
export async function getSettingBool(key: string): Promise<boolean> {
const value = await getSetting(key);
return value === 'true';
}
export async function setSetting(key: string, value: string): Promise<void> {
await prisma.appSetting.upsert({
where: { key },
update: { value },
create: { key, value },
});
}
export async function getAllSettings(): Promise<Record<string, string>> {
const settings = await prisma.appSetting.findMany();
// Start with defaults, then override with stored values
const result = { ...DEFAULT_SETTINGS };
for (const setting of settings) {
result[setting.key] = setting.value;
}
return result;
}
export async function getPublicSettings(): Promise<Record<string, string>> {
// Settings that should be available to all authenticated users (including customers)
const publicKeys = ['customerSupportTicketsEnabled'];
const allSettings = await getAllSettings();
const result: Record<string, string> = {};
for (const key of publicKeys) {
if (key in allSettings) {
result[key] = allSettings[key];
}
}
return result;
}
+340
View File
@@ -0,0 +1,340 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { JwtPayload } from '../types/index.js';
import { encrypt, decrypt } from '../utils/encryption.js';
const prisma = new PrismaClient();
// Mitarbeiter-Login
export async function login(email: string, password: string) {
const user = await prisma.user.findUnique({
where: { email },
include: {
roles: {
include: {
role: {
include: {
permissions: {
include: {
permission: true,
},
},
},
},
},
},
},
});
if (!user || !user.isActive) {
throw new Error('Ungültige Anmeldedaten');
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
throw new Error('Ungültige Anmeldedaten');
}
// Collect all permissions from all roles
const permissions = new Set<string>();
for (const userRole of user.roles) {
for (const rolePerm of userRole.role.permissions) {
permissions.add(
`${rolePerm.permission.resource}:${rolePerm.permission.action}`
);
}
}
const payload: JwtPayload = {
userId: user.id,
email: user.email,
permissions: Array.from(permissions),
customerId: user.customerId ?? undefined,
isCustomerPortal: false,
};
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
});
return {
token,
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
permissions: Array.from(permissions),
customerId: user.customerId,
isCustomerPortal: false,
},
};
}
// Kundenportal-Login
export async function customerLogin(email: string, password: string) {
console.log('[CustomerLogin] Versuch mit E-Mail:', email);
const customer = await prisma.customer.findUnique({
where: { portalEmail: email },
include: {
// Kunden, die dieser Kunde vertreten kann
representingFor: {
where: { isActive: true },
include: {
customer: {
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
type: true,
},
},
},
},
},
});
console.log('[CustomerLogin] Kunde gefunden:', customer ? `ID ${customer.id}, portalEnabled: ${customer.portalEnabled}, hasPasswordHash: ${!!customer.portalPasswordHash}` : 'NEIN');
if (!customer || !customer.portalEnabled || !customer.portalPasswordHash) {
console.log('[CustomerLogin] Abbruch: Kunde nicht gefunden oder Portal nicht aktiviert');
throw new Error('Ungültige Anmeldedaten');
}
const isValid = await bcrypt.compare(password, customer.portalPasswordHash);
console.log('[CustomerLogin] Passwort-Check:', isValid ? 'OK' : 'FALSCH');
if (!isValid) {
throw new Error('Ungültige Anmeldedaten');
}
// Letzte Anmeldung aktualisieren
await prisma.customer.update({
where: { id: customer.id },
data: { portalLastLogin: new Date() },
});
// IDs der Kunden sammeln, die dieser Kunde vertreten kann
const representedCustomerIds = customer.representingFor.map(
(rep) => rep.customer.id
);
// Kundenportal-Berechtigungen (eingeschränkt)
const customerPermissions = [
'contracts:read', // Eigene Verträge lesen
'customers:read', // Eigene Kundendaten lesen
];
const payload: JwtPayload = {
email: customer.portalEmail!,
permissions: customerPermissions,
customerId: customer.id,
isCustomerPortal: true,
representedCustomerIds,
};
const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', {
expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'],
});
return {
token,
user: {
id: customer.id,
email: customer.portalEmail,
firstName: customer.firstName,
lastName: customer.lastName,
permissions: customerPermissions,
customerId: customer.id,
isCustomerPortal: true,
representedCustomers: customer.representingFor.map((rep) => ({
id: rep.customer.id,
customerNumber: rep.customer.customerNumber,
firstName: rep.customer.firstName,
lastName: rep.customer.lastName,
companyName: rep.customer.companyName,
type: rep.customer.type,
})),
},
};
}
// Kundenportal-Passwort setzen/ändern
export async function setCustomerPortalPassword(customerId: number, password: string) {
console.log('[SetPortalPassword] Setze Passwort für Kunde:', customerId);
const hashedPassword = await bcrypt.hash(password, 10);
const encryptedPassword = encrypt(password);
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
await prisma.customer.update({
where: { id: customerId },
data: {
portalPasswordHash: hashedPassword,
portalPasswordEncrypted: encryptedPassword,
},
});
console.log('[SetPortalPassword] Passwort gespeichert');
}
// Kundenportal-Passwort im Klartext abrufen
export async function getCustomerPortalPassword(customerId: number): Promise<string | null> {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
select: { portalPasswordEncrypted: true },
});
if (!customer?.portalPasswordEncrypted) {
return null;
}
try {
return decrypt(customer.portalPasswordEncrypted);
} catch (error) {
console.error('Fehler beim Entschlüsseln des Passworts:', error);
return null;
}
}
export async function createUser(data: {
email: string;
password: string;
firstName: string;
lastName: string;
roleIds: number[];
customerId?: number;
}) {
const hashedPassword = await bcrypt.hash(data.password, 10);
const user = await prisma.user.create({
data: {
email: data.email,
password: hashedPassword,
firstName: data.firstName,
lastName: data.lastName,
customerId: data.customerId,
roles: {
create: data.roleIds.map((roleId) => ({ roleId })),
},
},
include: {
roles: {
include: {
role: true,
},
},
},
});
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
roles: user.roles.map((ur) => ur.role.name),
};
}
export async function getUserById(id: number) {
const user = await prisma.user.findUnique({
where: { id },
include: {
roles: {
include: {
role: {
include: {
permissions: {
include: {
permission: true,
},
},
},
},
},
},
},
});
if (!user) return null;
console.log('auth.getUserById - user roles:', user.roles.map(ur => ur.role.name));
const permissions = new Set<string>();
for (const userRole of user.roles) {
for (const rolePerm of userRole.role.permissions) {
permissions.add(
`${rolePerm.permission.resource}:${rolePerm.permission.action}`
);
}
}
console.log('auth.getUserById - permissions:', Array.from(permissions));
return {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
isActive: user.isActive,
customerId: user.customerId,
roles: user.roles.map((ur) => ur.role.name),
permissions: Array.from(permissions),
isCustomerPortal: false,
};
}
// Kundenportal-Benutzer laden (für /me Endpoint)
export async function getCustomerPortalUser(customerId: number) {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
include: {
representingFor: {
where: { isActive: true },
include: {
customer: {
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
type: true,
},
},
},
},
},
});
if (!customer || !customer.portalEnabled) return null;
const customerPermissions = [
'contracts:read',
'customers:read',
];
return {
id: customer.id,
email: customer.portalEmail,
firstName: customer.firstName,
lastName: customer.lastName,
isActive: customer.portalEnabled,
customerId: customer.id,
permissions: customerPermissions,
isCustomerPortal: true,
representedCustomers: customer.representingFor.map((rep) => ({
id: rep.customer.id,
customerNumber: rep.customer.customerNumber,
firstName: rep.customer.firstName,
lastName: rep.customer.lastName,
companyName: rep.customer.companyName,
type: rep.customer.type,
})),
};
}
@@ -0,0 +1,63 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function getAllCancellationPeriods(includeInactive = false) {
const where = includeInactive ? {} : { isActive: true };
return prisma.cancellationPeriod.findMany({
where,
orderBy: { code: 'asc' },
});
}
export async function getCancellationPeriodById(id: number) {
return prisma.cancellationPeriod.findUnique({
where: { id },
include: {
_count: {
select: { contracts: true },
},
},
});
}
export async function createCancellationPeriod(data: {
code: string;
description: string;
}) {
return prisma.cancellationPeriod.create({
data: {
...data,
isActive: true,
},
});
}
export async function updateCancellationPeriod(
id: number,
data: {
code?: string;
description?: string;
isActive?: boolean;
}
) {
return prisma.cancellationPeriod.update({
where: { id },
data,
});
}
export async function deleteCancellationPeriod(id: number) {
// Check if cancellation period is used by any contracts
const count = await prisma.contract.count({
where: { cancellationPeriodId: id },
});
if (count > 0) {
throw new Error(
`Kündigungsfrist kann nicht gelöscht werden, da sie von ${count} Verträgen verwendet wird`
);
}
return prisma.cancellationPeriod.delete({ where: { id } });
}
@@ -0,0 +1,63 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function getAllContractDurations(includeInactive = false) {
const where = includeInactive ? {} : { isActive: true };
return prisma.contractDuration.findMany({
where,
orderBy: { code: 'asc' },
});
}
export async function getContractDurationById(id: number) {
return prisma.contractDuration.findUnique({
where: { id },
include: {
_count: {
select: { contracts: true },
},
},
});
}
export async function createContractDuration(data: {
code: string;
description: string;
}) {
return prisma.contractDuration.create({
data: {
...data,
isActive: true,
},
});
}
export async function updateContractDuration(
id: number,
data: {
code?: string;
description?: string;
isActive?: boolean;
}
) {
return prisma.contractDuration.update({
where: { id },
data,
});
}
export async function deleteContractDuration(id: number) {
// Check if contract duration is used by any contracts
const count = await prisma.contract.count({
where: { contractDurationId: id },
});
if (count > 0) {
throw new Error(
`Laufzeit kann nicht gelöscht werden, da sie von ${count} Verträgen verwendet wird`
);
}
return prisma.contractDuration.delete({ where: { id } });
}
+780
View File
@@ -0,0 +1,780 @@
import { PrismaClient, ContractType, ContractStatus } from '@prisma/client';
import { generateContractNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
import { encrypt, decrypt } from '../utils/encryption.js';
const prisma = new PrismaClient();
export interface ContractFilters {
customerId?: number;
customerIds?: number[]; // Für Kundenportal: eigene ID + vertretene Kunden
type?: ContractType;
status?: ContractStatus;
search?: string;
page?: number;
limit?: number;
}
export async function getAllContracts(filters: ContractFilters) {
const { customerId, customerIds, type, status, search, page = 1, limit = 20 } = filters;
const { skip, take } = paginate(page, limit);
const where: Record<string, unknown> = {};
// Entweder einzelne customerId ODER Liste von customerIds (für Kundenportal)
if (customerIds && customerIds.length > 0) {
where.customerId = { in: customerIds };
} else if (customerId) {
where.customerId = customerId;
}
if (type) where.type = type;
// Status-Filter: Deaktivierte Verträge standardmäßig ausblenden
if (status) {
where.status = status;
} else {
// Wenn kein Status-Filter gesetzt, alle außer DEACTIVATED anzeigen
where.status = { not: ContractStatus.DEACTIVATED };
}
if (search) {
where.OR = [
// Basis-Vertragsfelder
{ contractNumber: { contains: search } },
{ providerName: { contains: search } },
{ tariffName: { contains: search } },
{ customerNumberAtProvider: { contains: search } },
{ provider: { name: { contains: search } } },
{ tariff: { name: { contains: search } } },
// Kundenname
{ customer: { firstName: { contains: search } } },
{ customer: { lastName: { contains: search } } },
{ customer: { companyName: { contains: search } } },
{ customer: { customerNumber: { contains: search } } },
// Internet-Vertragsdetails
{ internetDetails: { routerSerialNumber: { contains: search } } },
{ internetDetails: { homeId: { contains: search } } },
{ internetDetails: { activationCode: { contains: search } } },
{ internetDetails: { phoneNumbers: { some: { phoneNumber: { contains: search } } } } },
// Mobilfunk-Vertragsdetails
{ mobileDetails: { phoneNumber: { contains: search } } },
{ mobileDetails: { simCardNumber: { contains: search } } },
{ mobileDetails: { deviceImei: { contains: search } } },
{ mobileDetails: { simCards: { some: { phoneNumber: { contains: search } } } } },
{ mobileDetails: { simCards: { some: { simCardNumber: { contains: search } } } } },
// Energie-Vertragsdetails (Zählernummer)
{ energyDetails: { meter: { meterNumber: { contains: search } } } },
// TV-Vertragsdetails
{ tvDetails: { smartcardNumber: { contains: search } } },
// KFZ-Versicherung
{ carInsuranceDetails: { licensePlate: { contains: search } } },
{ carInsuranceDetails: { vin: { contains: search } } },
{ carInsuranceDetails: { policyNumber: { contains: search } } },
];
}
const [contracts, total] = await Promise.all([
prisma.contract.findMany({
where,
skip,
take,
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
include: {
customer: {
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
},
},
address: true,
salesPlatform: true,
cancellationPeriod: true,
contractDuration: true,
provider: true,
tariff: true,
contractCategory: true,
},
}),
prisma.contract.count({ where }),
]);
return {
contracts,
pagination: buildPaginationResponse(page, limit, total),
};
}
export async function getContractById(id: number, decryptPassword = false) {
const contract = await prisma.contract.findUnique({
where: { id },
include: {
customer: true,
address: true,
bankCard: true,
identityDocument: true,
salesPlatform: true,
cancellationPeriod: true,
contractDuration: true,
provider: true,
tariff: true,
contractCategory: true,
previousContract: {
include: {
energyDetails: { include: { meter: { include: { readings: true } } } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
carInsuranceDetails: true,
},
},
energyDetails: { include: { meter: { include: { readings: true } } } },
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
carInsuranceDetails: true,
stressfreiEmail: true,
followUpContract: {
select: { id: true, contractNumber: true, status: true },
},
},
});
if (!contract) return null;
// Decrypt password if requested and exists
if (decryptPassword && contract.portalPasswordEncrypted) {
try {
(contract as Record<string, unknown>).portalPasswordDecrypted = decrypt(
contract.portalPasswordEncrypted
);
} catch {
// Password decryption failed, leave as is
}
}
return contract;
}
interface ContractCreateData {
customerId: number;
type: ContractType;
contractCategoryId?: number;
status?: ContractStatus;
addressId?: number;
bankCardId?: number;
identityDocumentId?: number;
salesPlatformId?: number;
previousContractId?: number;
providerId?: number;
tariffId?: number;
providerName?: string;
tariffName?: string;
customerNumberAtProvider?: string;
priceFirst12Months?: string;
priceFrom13Months?: string;
priceAfter24Months?: string;
startDate?: Date;
endDate?: Date;
cancellationPeriodId?: number;
contractDurationId?: number;
commission?: number;
portalUsername?: string;
portalPassword?: string;
stressfreiEmailId?: number;
notes?: string;
// Kündigungsdaten
cancellationConfirmationDate?: Date;
cancellationConfirmationOptionsDate?: Date;
wasSpecialCancellation?: boolean;
// Type-specific details
energyDetails?: {
meterId?: number;
annualConsumption?: number;
basePrice?: number;
unitPrice?: number;
bonus?: number;
previousProviderName?: string;
previousCustomerNumber?: string;
};
internetDetails?: {
downloadSpeed?: number;
uploadSpeed?: number;
routerModel?: string;
routerSerialNumber?: string;
installationDate?: Date;
// Internet-Zugangsdaten
internetUsername?: string;
internetPassword?: string;
// Glasfaser-spezifisch
homeId?: string;
// Vodafone DSL/Kabel spezifisch
activationCode?: string;
phoneNumbers?: {
id?: number;
phoneNumber: string;
isMain?: boolean;
sipUsername?: string;
sipPassword?: string;
sipServer?: string;
}[];
};
mobileDetails?: {
requiresMultisim?: boolean;
dataVolume?: number;
includedMinutes?: number;
includedSMS?: number;
deviceModel?: string;
deviceImei?: string;
// Legacy-Felder
phoneNumber?: string;
simCardNumber?: string;
// SIM-Karten Liste
simCards?: {
id?: number;
phoneNumber?: string;
simCardNumber?: string;
pin?: string;
puk?: string;
isMultisim?: boolean;
isMain?: boolean;
}[];
};
tvDetails?: {
receiverModel?: string;
smartcardNumber?: string;
package?: string;
};
carInsuranceDetails?: {
licensePlate?: string;
hsn?: string;
tsn?: string;
vin?: string;
vehicleType?: string;
firstRegistration?: Date;
noClaimsClass?: string;
insuranceType?: 'LIABILITY' | 'PARTIAL' | 'FULL';
deductiblePartial?: number;
deductibleFull?: number;
policyNumber?: string;
previousInsurer?: string;
};
}
export async function createContract(data: ContractCreateData) {
const {
energyDetails,
internetDetails,
mobileDetails,
tvDetails,
carInsuranceDetails,
portalPassword,
...contractData
} = data;
// Encrypt password if provided
const portalPasswordEncrypted = portalPassword
? encrypt(portalPassword)
: undefined;
const contract = await prisma.contract.create({
data: {
...contractData,
contractNumber: generateContractNumber(data.type),
portalPasswordEncrypted,
...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type)
? { energyDetails: { create: energyDetails } }
: {}),
...(internetDetails && ['DSL', 'CABLE', 'FIBER'].includes(data.type)
? {
internetDetails: {
create: {
downloadSpeed: internetDetails.downloadSpeed,
uploadSpeed: internetDetails.uploadSpeed,
routerModel: internetDetails.routerModel,
routerSerialNumber: internetDetails.routerSerialNumber,
installationDate: internetDetails.installationDate,
internetUsername: internetDetails.internetUsername,
internetPasswordEncrypted: internetDetails.internetPassword
? encrypt(internetDetails.internetPassword)
: undefined,
homeId: internetDetails.homeId,
activationCode: internetDetails.activationCode,
phoneNumbers: internetDetails.phoneNumbers && internetDetails.phoneNumbers.length > 0
? {
create: internetDetails.phoneNumbers.map((pn) => ({
phoneNumber: pn.phoneNumber,
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
sipPasswordEncrypted: pn.sipPassword
? encrypt(pn.sipPassword)
: undefined,
sipServer: pn.sipServer,
})),
}
: undefined,
},
},
}
: {}),
...(mobileDetails && data.type === 'MOBILE'
? {
mobileDetails: {
create: {
requiresMultisim: mobileDetails.requiresMultisim,
dataVolume: mobileDetails.dataVolume,
includedMinutes: mobileDetails.includedMinutes,
includedSMS: mobileDetails.includedSMS,
deviceModel: mobileDetails.deviceModel,
deviceImei: mobileDetails.deviceImei,
phoneNumber: mobileDetails.phoneNumber,
simCardNumber: mobileDetails.simCardNumber,
simCards: mobileDetails.simCards
? {
create: mobileDetails.simCards.map((sc) => ({
phoneNumber: sc.phoneNumber,
simCardNumber: sc.simCardNumber,
pin: sc.pin ? encrypt(sc.pin) : undefined,
puk: sc.puk ? encrypt(sc.puk) : undefined,
isMultisim: sc.isMultisim ?? false,
isMain: sc.isMain ?? false,
})),
}
: undefined,
},
},
}
: {}),
...(tvDetails && data.type === 'TV'
? { tvDetails: { create: tvDetails } }
: {}),
...(carInsuranceDetails && data.type === 'CAR_INSURANCE'
? { carInsuranceDetails: { create: carInsuranceDetails } }
: {}),
},
include: {
customer: true,
address: true,
salesPlatform: true,
energyDetails: true,
internetDetails: { include: { phoneNumbers: true } },
mobileDetails: { include: { simCards: true } },
tvDetails: true,
carInsuranceDetails: true,
},
});
return contract;
}
export async function updateContract(
id: number,
data: Partial<ContractCreateData>
) {
const {
energyDetails,
internetDetails,
mobileDetails,
tvDetails,
carInsuranceDetails,
portalPassword,
...contractData
} = data;
// Encrypt password if provided
const portalPasswordEncrypted = portalPassword
? encrypt(portalPassword)
: undefined;
// Update main contract
await prisma.contract.update({
where: { id },
data: {
...contractData,
...(portalPasswordEncrypted ? { portalPasswordEncrypted } : {}),
},
});
// Update type-specific details
if (energyDetails) {
await prisma.energyContractDetails.upsert({
where: { contractId: id },
update: energyDetails,
create: { contractId: id, ...energyDetails },
});
}
if (internetDetails) {
const { phoneNumbers, internetPassword, ...internetData } = internetDetails;
const existing = await prisma.internetContractDetails.findUnique({
where: { contractId: id },
include: { phoneNumbers: true },
});
// Prepare internet data with encryption
const preparedInternetData = {
downloadSpeed: internetData.downloadSpeed,
uploadSpeed: internetData.uploadSpeed,
routerModel: internetData.routerModel,
routerSerialNumber: internetData.routerSerialNumber,
installationDate: internetData.installationDate,
internetUsername: internetData.internetUsername,
// Only update password if new value provided, otherwise keep existing
...(internetPassword
? { internetPasswordEncrypted: encrypt(internetPassword) }
: {}),
homeId: internetData.homeId,
activationCode: internetData.activationCode,
};
if (existing) {
await prisma.internetContractDetails.update({
where: { contractId: id },
data: preparedInternetData,
});
if (phoneNumbers) {
// Get existing phone numbers for preserving encrypted passwords
const existingPhoneNumbers = existing.phoneNumbers || [];
// Delete all existing phone numbers
await prisma.phoneNumber.deleteMany({
where: { internetContractDetailsId: existing.id },
});
// Create new phone numbers with encryption
await prisma.phoneNumber.createMany({
data: phoneNumbers.map((pn) => {
// Find existing entry to preserve sipPassword if not changed
const existingPn = pn.id
? existingPhoneNumbers.find((e) => e.id === pn.id)
: undefined;
return {
internetContractDetailsId: existing.id,
phoneNumber: pn.phoneNumber,
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
// Preserve existing sipPassword if no new value provided
sipPasswordEncrypted: pn.sipPassword
? encrypt(pn.sipPassword)
: existingPn?.sipPasswordEncrypted ?? undefined,
sipServer: pn.sipServer,
};
}),
});
}
} else {
await prisma.internetContractDetails.create({
data: {
contractId: id,
...preparedInternetData,
...(internetPassword
? { internetPasswordEncrypted: encrypt(internetPassword) }
: {}),
phoneNumbers: phoneNumbers
? {
create: phoneNumbers.map((pn) => ({
phoneNumber: pn.phoneNumber,
isMain: pn.isMain ?? false,
sipUsername: pn.sipUsername,
sipPasswordEncrypted: pn.sipPassword
? encrypt(pn.sipPassword)
: undefined,
sipServer: pn.sipServer,
})),
}
: undefined,
},
});
}
}
if (mobileDetails) {
const { simCards, ...mobileData } = mobileDetails;
const existing = await prisma.mobileContractDetails.findUnique({
where: { contractId: id },
});
if (existing) {
await prisma.mobileContractDetails.update({
where: { contractId: id },
data: mobileData,
});
if (simCards) {
// Get existing sim cards to preserve PIN/PUK if not provided
const existingSimCards = await prisma.simCard.findMany({
where: { mobileDetailsId: existing.id },
});
const existingSimCardMap = new Map(existingSimCards.map(sc => [sc.id, sc]));
// Delete existing sim cards
await prisma.simCard.deleteMany({
where: { mobileDetailsId: existing.id },
});
// Create new sim cards, preserving PIN/PUK if not provided
await prisma.simCard.createMany({
data: simCards.map((sc) => {
const existingSc = sc.id ? existingSimCardMap.get(sc.id) : undefined;
return {
mobileDetailsId: existing.id,
phoneNumber: sc.phoneNumber,
simCardNumber: sc.simCardNumber,
// Preserve existing PIN/PUK if no new value provided
pin: sc.pin ? encrypt(sc.pin) : (existingSc?.pin ?? undefined),
puk: sc.puk ? encrypt(sc.puk) : (existingSc?.puk ?? undefined),
isMultisim: sc.isMultisim ?? false,
isMain: sc.isMain ?? false,
};
}),
});
}
} else {
await prisma.mobileContractDetails.create({
data: {
contractId: id,
...mobileData,
simCards: simCards
? {
create: simCards.map((sc) => ({
phoneNumber: sc.phoneNumber,
simCardNumber: sc.simCardNumber,
pin: sc.pin ? encrypt(sc.pin) : undefined,
puk: sc.puk ? encrypt(sc.puk) : undefined,
isMultisim: sc.isMultisim ?? false,
isMain: sc.isMain ?? false,
})),
}
: undefined,
},
});
}
}
if (tvDetails) {
await prisma.tvContractDetails.upsert({
where: { contractId: id },
update: tvDetails,
create: { contractId: id, ...tvDetails },
});
}
if (carInsuranceDetails) {
await prisma.carInsuranceDetails.upsert({
where: { contractId: id },
update: carInsuranceDetails,
create: { contractId: id, ...carInsuranceDetails },
});
}
return getContractById(id);
}
export async function deleteContract(id: number) {
// Vertragskette erhalten beim Löschen:
// Wenn A → B → C und B gelöscht wird, soll C direkt auf A zeigen (A → C)
// 1. Zu löschenden Vertrag holen um dessen Vorgänger zu kennen
const contractToDelete = await prisma.contract.findUnique({
where: { id },
select: { previousContractId: true },
});
// 2. Folgevertrag(e) mit dem Vorgänger des gelöschten Vertrags verbinden
// So bleibt die Kette erhalten: A → B → C wird zu A → C
await prisma.contract.updateMany({
where: { previousContractId: id },
data: { previousContractId: contractToDelete?.previousContractId ?? null },
});
return prisma.contract.delete({ where: { id } });
}
export async function createFollowUpContract(previousContractId: number) {
const previousContract = await getContractById(previousContractId);
if (!previousContract) {
throw new Error('Vorgängervertrag nicht gefunden');
}
// Prüfen ob bereits ein Folgevertrag existiert
const existingFollowUp = await prisma.contract.findFirst({
where: { previousContractId },
select: { id: true, contractNumber: true },
});
if (existingFollowUp) {
throw new Error(`Es existiert bereits ein Folgevertrag: ${existingFollowUp.contractNumber}`);
}
// Copy data but exclude provider credentials and some fields
const newContractData: ContractCreateData = {
customerId: previousContract.customerId,
type: previousContract.type,
status: 'DRAFT',
addressId: previousContract.addressId ?? undefined,
bankCardId: previousContract.bankCardId ?? undefined,
identityDocumentId: previousContract.identityDocumentId ?? undefined,
salesPlatformId: previousContract.salesPlatformId ?? undefined,
previousContractId: previousContract.id,
// Explicitly NOT copying: providerName, tariffName, portalUsername, portalPassword, price fields
cancellationPeriodId: previousContract.cancellationPeriodId ?? undefined,
contractDurationId: previousContract.contractDurationId ?? undefined,
notes: `Folgevertrag zu ${previousContract.contractNumber}`,
};
// Copy type-specific details (without credentials)
if (previousContract.energyDetails) {
newContractData.energyDetails = {
meterId: previousContract.energyDetails.meterId ?? undefined,
annualConsumption:
previousContract.energyDetails.annualConsumption ?? undefined,
basePrice: previousContract.energyDetails.basePrice ?? undefined,
unitPrice: previousContract.energyDetails.unitPrice ?? undefined,
bonus: previousContract.energyDetails.bonus ?? undefined,
previousProviderName: previousContract.providerName ?? undefined,
previousCustomerNumber:
previousContract.customerNumberAtProvider ?? undefined,
};
}
if (previousContract.internetDetails) {
newContractData.internetDetails = {
downloadSpeed:
previousContract.internetDetails.downloadSpeed ?? undefined,
uploadSpeed: previousContract.internetDetails.uploadSpeed ?? undefined,
routerModel: previousContract.internetDetails.routerModel ?? undefined,
routerSerialNumber:
previousContract.internetDetails.routerSerialNumber ?? undefined,
phoneNumbers: previousContract.internetDetails.phoneNumbers.map((pn) => ({
phoneNumber: pn.phoneNumber,
isMain: pn.isMain,
})),
};
}
if (previousContract.mobileDetails) {
newContractData.mobileDetails = {
requiresMultisim: previousContract.mobileDetails.requiresMultisim ?? undefined,
dataVolume: previousContract.mobileDetails.dataVolume ?? undefined,
includedMinutes:
previousContract.mobileDetails.includedMinutes ?? undefined,
includedSMS: previousContract.mobileDetails.includedSMS ?? undefined,
deviceModel: previousContract.mobileDetails.deviceModel ?? undefined,
deviceImei: previousContract.mobileDetails.deviceImei ?? undefined,
phoneNumber: previousContract.mobileDetails.phoneNumber ?? undefined,
simCardNumber: previousContract.mobileDetails.simCardNumber ?? undefined,
// Copy simCards without PIN/PUK (security)
simCards: previousContract.mobileDetails.simCards?.map((sc) => ({
phoneNumber: sc.phoneNumber ?? undefined,
simCardNumber: sc.simCardNumber ?? undefined,
isMultisim: sc.isMultisim,
isMain: sc.isMain,
})),
};
}
if (previousContract.tvDetails) {
newContractData.tvDetails = {
receiverModel: previousContract.tvDetails.receiverModel ?? undefined,
smartcardNumber:
previousContract.tvDetails.smartcardNumber ?? undefined,
package: previousContract.tvDetails.package ?? undefined,
};
}
if (previousContract.carInsuranceDetails) {
newContractData.carInsuranceDetails = {
licensePlate:
previousContract.carInsuranceDetails.licensePlate ?? undefined,
hsn: previousContract.carInsuranceDetails.hsn ?? undefined,
tsn: previousContract.carInsuranceDetails.tsn ?? undefined,
vin: previousContract.carInsuranceDetails.vin ?? undefined,
vehicleType: previousContract.carInsuranceDetails.vehicleType ?? undefined,
firstRegistration:
previousContract.carInsuranceDetails.firstRegistration ?? undefined,
noClaimsClass:
previousContract.carInsuranceDetails.noClaimsClass ?? undefined,
insuranceType: previousContract.carInsuranceDetails.insuranceType,
deductiblePartial:
previousContract.carInsuranceDetails.deductiblePartial ?? undefined,
deductibleFull:
previousContract.carInsuranceDetails.deductibleFull ?? undefined,
previousInsurer: previousContract.providerName ?? undefined,
};
}
return createContract(newContractData);
}
// Decrypt password for viewing
export async function getContractPassword(id: number): Promise<string | null> {
const contract = await prisma.contract.findUnique({
where: { id },
select: { portalPasswordEncrypted: true },
});
if (!contract?.portalPasswordEncrypted) return null;
try {
return decrypt(contract.portalPasswordEncrypted);
} catch {
return null;
}
}
// Decrypt SimCard PIN/PUK
export async function getSimCardCredentials(simCardId: number): Promise<{ pin: string | null; puk: string | null }> {
const simCard = await prisma.simCard.findUnique({
where: { id: simCardId },
select: { pin: true, puk: true },
});
if (!simCard) return { pin: null, puk: null };
try {
return {
pin: simCard.pin ? decrypt(simCard.pin) : null,
puk: simCard.puk ? decrypt(simCard.puk) : null,
};
} catch {
return { pin: null, puk: null };
}
}
// Decrypt Internet password
export async function getInternetCredentials(contractId: number): Promise<{ password: string | null }> {
const internetDetails = await prisma.internetContractDetails.findUnique({
where: { contractId },
select: { internetPasswordEncrypted: true },
});
if (!internetDetails?.internetPasswordEncrypted) return { password: null };
try {
return {
password: decrypt(internetDetails.internetPasswordEncrypted),
};
} catch {
return { password: null };
}
}
// Decrypt SIP password for a phone number
export async function getSipCredentials(phoneNumberId: number): Promise<{ password: string | null }> {
const phoneNumber = await prisma.phoneNumber.findUnique({
where: { id: phoneNumberId },
select: { sipPasswordEncrypted: true },
});
if (!phoneNumber?.sipPasswordEncrypted) return { password: null };
try {
return {
password: decrypt(phoneNumber.sipPasswordEncrypted),
};
} catch {
return { password: null };
}
}
@@ -0,0 +1,78 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function getAllContractCategories(includeInactive = false) {
return prisma.contractCategory.findMany({
where: includeInactive ? {} : { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
include: {
_count: {
select: { contracts: true },
},
},
});
}
export async function getContractCategoryById(id: number) {
return prisma.contractCategory.findUnique({
where: { id },
include: {
_count: {
select: { contracts: true },
},
},
});
}
export async function getContractCategoryByCode(code: string) {
return prisma.contractCategory.findUnique({
where: { code },
});
}
interface ContractCategoryCreateData {
code: string;
name: string;
icon?: string;
color?: string;
sortOrder?: number;
isActive?: boolean;
}
export async function createContractCategory(data: ContractCategoryCreateData) {
return prisma.contractCategory.create({
data,
include: {
_count: {
select: { contracts: true },
},
},
});
}
export async function updateContractCategory(id: number, data: Partial<ContractCategoryCreateData>) {
return prisma.contractCategory.update({
where: { id },
data,
include: {
_count: {
select: { contracts: true },
},
},
});
}
export async function deleteContractCategory(id: number) {
// Check if category has contracts
const category = await prisma.contractCategory.findUnique({
where: { id },
include: { _count: { select: { contracts: true } } },
});
if (category && category._count.contracts > 0) {
throw new Error(`Kategorie kann nicht gelöscht werden, da ${category._count.contracts} Verträge zugeordnet sind.`);
}
return prisma.contractCategory.delete({ where: { id } });
}
@@ -0,0 +1,475 @@
import { PrismaClient, ContractStatus
} from '@prisma/client';
import * as appSettingService from './appSetting.service.js';
const prisma = new PrismaClient();
// Typen für das Cockpit
export type UrgencyLevel = 'critical' | 'warning' | 'ok' | 'none';
export interface CockpitIssue {
type: string;
label: string;
urgency: UrgencyLevel;
daysRemaining?: number;
details?: string;
}
export interface CockpitContract {
id: number;
contractNumber: string;
type: string;
status: ContractStatus;
customer: {
id: number;
customerNumber: string;
name: string;
};
provider?: {
id: number;
name: string;
};
tariff?: {
id: number;
name: string;
};
providerName?: string;
tariffName?: string;
issues: CockpitIssue[];
highestUrgency: UrgencyLevel;
}
export interface CockpitSummary {
totalContracts: number;
criticalCount: number;
warningCount: number;
okCount: number;
byCategory: {
cancellationDeadlines: number;
contractEnding: number;
missingCredentials: number;
missingData: number;
openTasks: number;
pendingContracts: number;
};
}
export interface CockpitResult {
contracts: CockpitContract[];
summary: CockpitSummary;
thresholds: {
criticalDays: number;
warningDays: number;
okDays: number;
};
}
// Hilfsfunktion: Tage bis zu einem Datum berechnen
function daysUntil(date: Date | null | undefined): number | null {
if (!date) return null;
const now = new Date();
now.setHours(0, 0, 0, 0);
const target = new Date(date);
target.setHours(0, 0, 0, 0);
const diff = target.getTime() - now.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24));
}
// Hilfsfunktion: Urgency basierend auf Tagen bestimmen
function getUrgencyByDays(
daysRemaining: number | null,
criticalDays: number,
warningDays: number,
okDays: number
): UrgencyLevel {
if (daysRemaining === null) return 'none';
if (daysRemaining < 0) return 'critical'; // Bereits überfällig
if (daysRemaining <= criticalDays) return 'critical';
if (daysRemaining <= warningDays) return 'warning';
if (daysRemaining <= okDays) return 'ok';
return 'none';
}
// Hilfsfunktion: Höchste Dringlichkeit ermitteln
function getHighestUrgency(issues: CockpitIssue[]): UrgencyLevel {
const levels: UrgencyLevel[] = ['critical', 'warning', 'ok', 'none'];
for (const level of levels) {
if (issues.some(i => i.urgency === level)) {
return level;
}
}
return 'none';
}
// Kündigungsfrist berechnen
function calculateCancellationDeadline(
endDate: Date | null | undefined,
cancellationPeriodCode: string | null | undefined
): Date | null {
if (!endDate || !cancellationPeriodCode) return null;
const end = new Date(endDate);
// Parse Kündigungsperiode (z.B. "1M" = 1 Monat, "6W" = 6 Wochen, "14D" = 14 Tage)
const match = cancellationPeriodCode.match(/^(\d+)([DMWY])$/i);
if (!match) return null;
const amount = parseInt(match[1]);
const unit = match[2].toUpperCase();
switch (unit) {
case 'D':
end.setDate(end.getDate() - amount);
break;
case 'W':
end.setDate(end.getDate() - (amount * 7));
break;
case 'M':
end.setMonth(end.getMonth() - amount);
break;
case 'Y':
end.setFullYear(end.getFullYear() - amount);
break;
}
return end;
}
export async function getCockpitData(): Promise<CockpitResult> {
// Lade Einstellungen
const settings = await appSettingService.getAllSettings();
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
const warningDays = parseInt(settings.deadlineWarningDays) || 42;
const okDays = parseInt(settings.deadlineOkDays) || 90;
// Lade alle aktiven/pending Verträge mit allen relevanten Daten
const contracts = await prisma.contract.findMany({
where: {
status: {
in: ['ACTIVE', 'PENDING', 'DRAFT'],
},
},
include: {
customer: {
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
},
},
provider: {
select: {
id: true,
name: true,
},
},
tariff: {
select: {
id: true,
name: true,
},
},
cancellationPeriod: {
select: {
code: true,
},
},
address: true,
bankCard: true,
identityDocument: true,
energyDetails: {
include: {
meter: true,
},
},
internetDetails: {
include: {
phoneNumbers: true,
},
},
mobileDetails: {
include: {
simCards: true,
},
},
tasks: {
where: {
status: 'OPEN',
},
},
// Folgevertrag laden um zu prüfen ob dieser aktiv ist
followUpContract: {
select: {
id: true,
status: true,
},
},
},
orderBy: [
{ endDate: 'asc' },
{ createdAt: 'desc' },
],
});
const cockpitContracts: CockpitContract[] = [];
const summary: CockpitSummary = {
totalContracts: 0,
criticalCount: 0,
warningCount: 0,
okCount: 0,
byCategory: {
cancellationDeadlines: 0,
contractEnding: 0,
missingCredentials: 0,
missingData: 0,
openTasks: 0,
pendingContracts: 0,
},
};
for (const contract of contracts) {
const issues: CockpitIssue[] = [];
// Prüfen ob aktiver Folgevertrag existiert - dann keine Kündigungswarnungen nötig
const hasActiveFollowUp = contract.followUpContract?.status === 'ACTIVE';
// 1. KÜNDIGUNGSFRIST (nur wenn kein aktiver Folgevertrag)
if (!hasActiveFollowUp) {
const cancellationDeadline = calculateCancellationDeadline(
contract.endDate,
contract.cancellationPeriod?.code
);
const daysToCancellation = daysUntil(cancellationDeadline);
if (daysToCancellation !== null && daysToCancellation <= okDays) {
const urgency = getUrgencyByDays(daysToCancellation, criticalDays, warningDays, okDays);
if (urgency !== 'none') {
issues.push({
type: 'cancellation_deadline',
label: 'Kündigungsfrist',
urgency,
daysRemaining: daysToCancellation,
details: daysToCancellation < 0
? `Frist seit ${Math.abs(daysToCancellation)} Tagen überschritten!`
: `Noch ${daysToCancellation} Tage bis zur Kündigungsfrist`,
});
summary.byCategory.cancellationDeadlines++;
}
// 1a. KÜNDIGUNG NICHT GESENDET (wenn Frist naht)
if (!contract.cancellationLetterPath) {
issues.push({
type: 'missing_cancellation_letter',
label: 'Kündigung nicht gesendet',
urgency,
details: 'Kündigungsschreiben wurde noch nicht hochgeladen',
});
summary.byCategory.missingData++;
}
// 1b. KÜNDIGUNGSBESTÄTIGUNG FEHLT (wenn Kündigung gesendet aber keine Bestätigung)
if (contract.cancellationLetterPath && !contract.cancellationConfirmationPath && !contract.cancellationConfirmationDate) {
issues.push({
type: 'missing_cancellation_confirmation',
label: 'Kündigungsbestätigung fehlt',
urgency: urgency === 'critical' ? 'critical' : 'warning',
details: 'Kündigungsbestätigung vom Anbieter wurde noch nicht erhalten',
});
summary.byCategory.missingData++;
}
}
}
// 2. VERTRAGSENDE
const daysToEnd = daysUntil(contract.endDate);
if (daysToEnd !== null && daysToEnd <= okDays) {
const urgency = getUrgencyByDays(daysToEnd, criticalDays, warningDays, okDays);
if (urgency !== 'none') {
issues.push({
type: 'contract_ending',
label: 'Vertragsende',
urgency,
daysRemaining: daysToEnd,
details: daysToEnd < 0
? `Vertrag seit ${Math.abs(daysToEnd)} Tagen abgelaufen!`
: `Noch ${daysToEnd} Tage bis Vertragsende`,
});
summary.byCategory.contractEnding++;
}
}
// 3. FEHLENDE PORTAL-ZUGANGSDATEN
if (!contract.portalUsername || !contract.portalPasswordEncrypted) {
issues.push({
type: 'missing_portal_credentials',
label: 'Portal-Zugangsdaten fehlen',
urgency: 'warning',
details: 'Benutzername oder Passwort für das Anbieter-Portal fehlt',
});
summary.byCategory.missingCredentials++;
}
// 4. KEINE KUNDENNUMMER BEIM ANBIETER
if (!contract.customerNumberAtProvider) {
issues.push({
type: 'missing_customer_number',
label: 'Kundennummer fehlt',
urgency: 'warning',
details: 'Kundennummer beim Anbieter fehlt',
});
summary.byCategory.missingData++;
}
// 5. KEIN ANBIETER/TARIF
if (!contract.providerId && !contract.providerName) {
issues.push({
type: 'missing_provider',
label: 'Anbieter fehlt',
urgency: 'warning',
details: 'Kein Anbieter ausgewählt',
});
summary.byCategory.missingData++;
}
// 6. KEINE ADRESSE
if (!contract.addressId) {
issues.push({
type: 'missing_address',
label: 'Adresse fehlt',
urgency: 'warning',
details: 'Keine Lieferadresse verknüpft',
});
summary.byCategory.missingData++;
}
// 7. KEINE BANKVERBINDUNG
if (!contract.bankCardId) {
issues.push({
type: 'missing_bank',
label: 'Bankverbindung fehlt',
urgency: 'warning',
details: 'Keine Bankverbindung verknüpft',
});
summary.byCategory.missingData++;
}
// 8. ENERGIE-SPEZIFISCH: KEIN ZÄHLER
if (['ELECTRICITY', 'GAS'].includes(contract.type) && contract.energyDetails) {
if (!contract.energyDetails.meterId) {
issues.push({
type: 'missing_meter',
label: 'Zähler fehlt',
urgency: 'warning',
details: 'Kein Zähler verknüpft',
});
summary.byCategory.missingData++;
}
}
// 9. MOBIL-SPEZIFISCH: SIM-KARTEN
if (contract.type === 'MOBILE' && contract.mobileDetails) {
if (!contract.mobileDetails.simCards || contract.mobileDetails.simCards.length === 0) {
issues.push({
type: 'missing_sim',
label: 'SIM-Karte fehlt',
urgency: 'warning',
details: 'Keine SIM-Karte eingetragen',
});
summary.byCategory.missingData++;
}
}
// 10. OFFENE AUFGABEN
if (contract.tasks && contract.tasks.length > 0) {
issues.push({
type: 'open_tasks',
label: 'Offene Aufgaben',
urgency: 'ok',
details: `${contract.tasks.length} offene Aufgabe(n)`,
});
summary.byCategory.openTasks++;
}
// 11. PENDING STATUS
if (contract.status === 'PENDING') {
issues.push({
type: 'pending_status',
label: 'Warte auf Aktivierung',
urgency: 'ok',
details: 'Vertrag noch nicht aktiv',
});
summary.byCategory.pendingContracts++;
}
// 12. DRAFT STATUS
if (contract.status === 'DRAFT') {
issues.push({
type: 'draft_status',
label: 'Entwurf',
urgency: 'warning',
details: 'Vertrag ist noch ein Entwurf',
});
summary.byCategory.pendingContracts++;
}
// Nur Verträge mit Issues hinzufügen
if (issues.length > 0) {
const highestUrgency = getHighestUrgency(issues);
const customerName = contract.customer.companyName ||
`${contract.customer.firstName} ${contract.customer.lastName}`;
cockpitContracts.push({
id: contract.id,
contractNumber: contract.contractNumber,
type: contract.type,
status: contract.status,
customer: {
id: contract.customer.id,
customerNumber: contract.customer.customerNumber,
name: customerName,
},
provider: contract.provider ? {
id: contract.provider.id,
name: contract.provider.name,
} : undefined,
tariff: contract.tariff ? {
id: contract.tariff.id,
name: contract.tariff.name,
} : undefined,
providerName: contract.providerName || undefined,
tariffName: contract.tariffName || undefined,
issues,
highestUrgency,
});
// Summary zählen
summary.totalContracts++;
if (highestUrgency === 'critical') summary.criticalCount++;
else if (highestUrgency === 'warning') summary.warningCount++;
else if (highestUrgency === 'ok') summary.okCount++;
}
}
// Sortiere nach Dringlichkeit
cockpitContracts.sort((a, b) => {
const urgencyOrder: Record<UrgencyLevel, number> = {
critical: 0,
warning: 1,
ok: 2,
none: 3,
};
return urgencyOrder[a.highestUrgency] - urgencyOrder[b.highestUrgency];
});
return {
contracts: cockpitContracts,
summary,
thresholds: {
criticalDays,
warningDays,
okDays,
},
};
}
@@ -0,0 +1,324 @@
import { PrismaClient, ContractTaskStatus } from '@prisma/client';
const prisma = new PrismaClient();
export interface ContractTaskFilters {
contractId: number;
status?: ContractTaskStatus;
visibleInPortal?: boolean;
// Für Kundenportal: Zeige Tasks die entweder sichtbar sind ODER vom Kunden erstellt wurden
customerPortalEmails?: string[];
}
export async function getTasksByContract(filters: ContractTaskFilters) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
contractId: filters.contractId,
};
if (filters.status) {
where.status = filters.status;
}
// Spezielle Logik für Kundenportal
if (filters.customerPortalEmails && filters.customerPortalEmails.length > 0) {
// Zeige Tasks die:
// 1. visibleInPortal = true ODER
// 2. vom Kunden selbst erstellt wurden (createdBy in customerPortalEmails)
where.OR = [
{ visibleInPortal: true },
{ createdBy: { in: filters.customerPortalEmails } },
];
} else if (filters.visibleInPortal !== undefined) {
where.visibleInPortal = filters.visibleInPortal;
}
return prisma.contractTask.findMany({
where,
include: {
subtasks: {
orderBy: [
{ status: 'asc' },
{ createdAt: 'asc' },
],
},
},
orderBy: [
{ status: 'asc' }, // OPEN first, then COMPLETED
{ createdAt: 'desc' },
],
});
}
export async function getTaskById(id: number) {
return prisma.contractTask.findUnique({
where: { id },
});
}
export async function createTask(data: {
contractId: number;
title: string;
description?: string;
visibleInPortal?: boolean;
createdBy?: string;
}) {
return prisma.contractTask.create({
data: {
contractId: data.contractId,
title: data.title,
description: data.description,
visibleInPortal: data.visibleInPortal ?? false,
createdBy: data.createdBy,
},
});
}
export async function updateTask(
id: number,
data: {
title?: string;
description?: string;
visibleInPortal?: boolean;
}
) {
return prisma.contractTask.update({
where: { id },
data,
});
}
export async function completeTask(id: number) {
return prisma.contractTask.update({
where: { id },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
});
}
export async function reopenTask(id: number) {
return prisma.contractTask.update({
where: { id },
data: {
status: 'OPEN',
completedAt: null,
},
});
}
export async function deleteTask(id: number) {
return prisma.contractTask.delete({
where: { id },
});
}
// ==================== SUBTASKS ====================
export async function createSubtask(data: { taskId: number; title: string; createdBy?: string }) {
return prisma.contractTaskSubtask.create({
data: {
taskId: data.taskId,
title: data.title,
createdBy: data.createdBy,
},
});
}
export async function updateSubtask(id: number, data: { title?: string }) {
return prisma.contractTaskSubtask.update({
where: { id },
data,
});
}
export async function completeSubtask(id: number) {
// Complete the subtask
const subtask = await prisma.contractTaskSubtask.update({
where: { id },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
});
// Check if all subtasks of the parent task are now completed
const remainingOpenSubtasks = await prisma.contractTaskSubtask.count({
where: {
taskId: subtask.taskId,
status: 'OPEN',
},
});
// If no open subtasks remain, automatically complete the parent task
if (remainingOpenSubtasks === 0) {
await prisma.contractTask.update({
where: { id: subtask.taskId },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
});
}
return subtask;
}
export async function reopenSubtask(id: number) {
// Reopen the subtask
const subtask = await prisma.contractTaskSubtask.update({
where: { id },
data: {
status: 'OPEN',
completedAt: null,
},
});
// If the parent task was completed, reopen it as well
const parentTask = await prisma.contractTask.findUnique({
where: { id: subtask.taskId },
});
if (parentTask?.status === 'COMPLETED') {
await prisma.contractTask.update({
where: { id: subtask.taskId },
data: {
status: 'OPEN',
completedAt: null,
},
});
}
return subtask;
}
export async function deleteSubtask(id: number) {
return prisma.contractTaskSubtask.delete({
where: { id },
});
}
export async function getSubtaskById(id: number) {
return prisma.contractTaskSubtask.findUnique({
where: { id },
include: { task: true },
});
}
// ==================== ALL TASKS ====================
export interface AllTasksFilters {
status?: ContractTaskStatus;
customerId?: number;
// Für Kundenportal: Nur Tasks für erlaubte Verträge und sichtbare/eigene Tasks
customerPortalCustomerIds?: number[];
customerPortalEmails?: string[];
}
export async function getAllTasks(filters: AllTasksFilters) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {};
if (filters.status) {
where.status = filters.status;
}
// Für Kundenportal: Filter auf erlaubte Verträge + Sichtbarkeit
if (filters.customerPortalCustomerIds && filters.customerPortalCustomerIds.length > 0) {
where.contract = {
customerId: { in: filters.customerPortalCustomerIds },
};
// Zeige nur sichtbare Tasks ODER vom Kunden erstellte
if (filters.customerPortalEmails && filters.customerPortalEmails.length > 0) {
where.OR = [
{ visibleInPortal: true },
{ createdBy: { in: filters.customerPortalEmails } },
];
} else {
where.visibleInPortal = true;
}
} else if (filters.customerId) {
// Für Mitarbeiter: Optional nach Kunde filtern
where.contract = {
customerId: filters.customerId,
};
}
return prisma.contractTask.findMany({
where,
include: {
subtasks: {
orderBy: [
{ status: 'asc' },
{ createdAt: 'asc' },
],
},
contract: {
select: {
id: true,
contractNumber: true,
customerId: true,
customer: {
select: {
id: true,
firstName: true,
lastName: true,
companyName: true,
customerNumber: true,
},
},
provider: {
select: {
id: true,
name: true,
},
},
tariff: {
select: {
id: true,
name: true,
},
},
providerName: true,
tariffName: true,
},
},
},
orderBy: [
{ status: 'asc' }, // OPEN first, then COMPLETED
{ createdAt: 'desc' },
],
});
}
export async function getTaskStats(filters: AllTasksFilters) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const where: any = {
status: 'OPEN',
};
// Für Kundenportal: Filter auf erlaubte Verträge + Sichtbarkeit
if (filters.customerPortalCustomerIds && filters.customerPortalCustomerIds.length > 0) {
where.contract = {
customerId: { in: filters.customerPortalCustomerIds },
};
// Zeige nur sichtbare Tasks ODER vom Kunden erstellte
if (filters.customerPortalEmails && filters.customerPortalEmails.length > 0) {
where.OR = [
{ visibleInPortal: true },
{ createdBy: { in: filters.customerPortalEmails } },
];
} else {
where.visibleInPortal = true;
}
} else if (filters.customerId) {
where.contract = {
customerId: filters.customerId,
};
}
const openCount = await prisma.contractTask.count({ where });
return { openCount };
}
+682
View File
@@ -0,0 +1,682 @@
import { PrismaClient, CustomerType, ContractStatus } from '@prisma/client';
import { generateCustomerNumber, paginate, buildPaginationResponse } from '../utils/helpers.js';
import fs from 'fs';
import path from 'path';
const prisma = new PrismaClient();
// Helper zum Löschen von Dateien
function deleteFileIfExists(filePath: string | null) {
if (!filePath) return;
const absolutePath = path.join(process.cwd(), filePath);
if (fs.existsSync(absolutePath)) {
try {
fs.unlinkSync(absolutePath);
} catch (error) {
console.error('Fehler beim Löschen der Datei:', absolutePath, error);
}
}
}
export interface CustomerFilters {
search?: string;
type?: CustomerType;
page?: number;
limit?: number;
}
export async function getAllCustomers(filters: CustomerFilters) {
const { search, type, page = 1, limit = 20 } = filters;
const { skip, take } = paginate(page, limit);
const where: Record<string, unknown> = {};
if (type) {
where.type = type;
}
if (search) {
where.OR = [
{ firstName: { contains: search } },
{ lastName: { contains: search } },
{ companyName: { contains: search } },
{ email: { contains: search } },
{ customerNumber: { contains: search } },
];
}
const [customers, total] = await Promise.all([
prisma.customer.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: {
addresses: { where: { isDefault: true }, take: 1 },
_count: {
select: { contracts: true },
},
},
}),
prisma.customer.count({ where }),
]);
return {
customers,
pagination: buildPaginationResponse(page, limit, total),
};
}
export async function getCustomerById(id: number) {
return prisma.customer.findUnique({
where: { id },
include: {
addresses: true,
bankCards: { orderBy: { isActive: 'desc' } },
identityDocuments: { orderBy: { isActive: 'desc' } },
meters: {
orderBy: { isActive: 'desc' },
include: {
readings: {
orderBy: { readingDate: 'desc' },
},
},
},
stressfreiEmails: { orderBy: { isActive: 'desc' } },
contracts: {
where: {
// Deaktivierte Verträge ausblenden
status: { not: ContractStatus.DEACTIVATED },
},
orderBy: [{ startDate: 'desc' }, { createdAt: 'desc' }],
include: {
address: true,
salesPlatform: true,
},
},
},
});
}
export async function getCustomersByIds(ids: number[]) {
return prisma.customer.findMany({
where: { id: { in: ids } },
select: {
id: true,
portalEmail: true,
},
});
}
export async function createCustomer(data: {
type?: CustomerType;
salutation?: string;
firstName: string;
lastName: string;
companyName?: string;
birthDate?: Date;
birthPlace?: string;
email?: string;
phone?: string;
mobile?: string;
taxNumber?: string;
businessRegistration?: string;
commercialRegister?: string;
notes?: string;
}) {
return prisma.customer.create({
data: {
...data,
customerNumber: generateCustomerNumber(),
},
});
}
export async function updateCustomer(
id: number,
data: {
type?: CustomerType;
salutation?: string;
firstName?: string;
lastName?: string;
companyName?: string;
birthDate?: Date;
birthPlace?: string;
email?: string;
phone?: string;
mobile?: string;
taxNumber?: string;
businessRegistration?: string;
commercialRegister?: string;
notes?: string;
}
) {
return prisma.customer.update({
where: { id },
data,
});
}
export async function deleteCustomer(id: number) {
// Vor dem Löschen: Alle Dokumente (Dateien) des Kunden löschen
const customer = await prisma.customer.findUnique({
where: { id },
select: { businessRegistrationPath: true, commercialRegisterPath: true, privacyPolicyPath: true },
});
const bankCards = await prisma.bankCard.findMany({
where: { customerId: id },
select: { documentPath: true },
});
const identityDocs = await prisma.identityDocument.findMany({
where: { customerId: id },
select: { documentPath: true },
});
// Kundendokumente löschen
if (customer) {
deleteFileIfExists(customer.businessRegistrationPath);
deleteFileIfExists(customer.commercialRegisterPath);
deleteFileIfExists(customer.privacyPolicyPath);
}
// Bankkarten- und Ausweisdokumente löschen
for (const card of bankCards) {
deleteFileIfExists(card.documentPath);
}
for (const doc of identityDocs) {
deleteFileIfExists(doc.documentPath);
}
// Jetzt DB-Eintrag löschen (Cascade löscht die verknüpften Einträge)
return prisma.customer.delete({
where: { id },
});
}
// Address operations
export async function getCustomerAddresses(customerId: number) {
return prisma.address.findMany({
where: { customerId },
orderBy: [{ isDefault: 'desc' }, { createdAt: 'desc' }],
});
}
export async function createAddress(
customerId: number,
data: {
type: 'DELIVERY_RESIDENCE' | 'BILLING';
street: string;
houseNumber: string;
postalCode: string;
city: string;
country?: string;
isDefault?: boolean;
}
) {
// If this is set as default, unset other defaults of same type
if (data.isDefault) {
await prisma.address.updateMany({
where: { customerId, type: data.type },
data: { isDefault: false },
});
}
return prisma.address.create({
data: {
customerId,
...data,
},
});
}
export async function updateAddress(
id: number,
data: {
type?: 'DELIVERY_RESIDENCE' | 'BILLING';
street?: string;
houseNumber?: string;
postalCode?: string;
city?: string;
country?: string;
isDefault?: boolean;
}
) {
const address = await prisma.address.findUnique({ where: { id } });
if (!address) throw new Error('Adresse nicht gefunden');
if (data.isDefault) {
await prisma.address.updateMany({
where: {
customerId: address.customerId,
type: data.type || address.type,
id: { not: id },
},
data: { isDefault: false },
});
}
return prisma.address.update({
where: { id },
data,
});
}
export async function deleteAddress(id: number) {
return prisma.address.delete({ where: { id } });
}
// Bank card operations
export async function getCustomerBankCards(
customerId: number,
showInactive: boolean = false
) {
const where: Record<string, unknown> = { customerId };
if (!showInactive) {
where.isActive = true;
}
return prisma.bankCard.findMany({
where,
orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }],
});
}
export async function createBankCard(
customerId: number,
data: {
accountHolder: string;
iban: string;
bic?: string;
bankName?: string;
expiryDate?: Date;
}
) {
return prisma.bankCard.create({
data: {
customerId,
...data,
isActive: true,
},
});
}
export async function updateBankCard(
id: number,
data: {
accountHolder?: string;
iban?: string;
bic?: string;
bankName?: string;
expiryDate?: Date;
isActive?: boolean;
}
) {
return prisma.bankCard.update({
where: { id },
data,
});
}
export async function deleteBankCard(id: number) {
// Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen
const bankCard = await prisma.bankCard.findUnique({ where: { id } });
if (bankCard?.documentPath) {
deleteFileIfExists(bankCard.documentPath);
}
return prisma.bankCard.delete({ where: { id } });
}
// Identity document operations
export async function getCustomerDocuments(
customerId: number,
showInactive: boolean = false
) {
const where: Record<string, unknown> = { customerId };
if (!showInactive) {
where.isActive = true;
}
return prisma.identityDocument.findMany({
where,
orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }],
});
}
export async function createDocument(
customerId: number,
data: {
type: 'ID_CARD' | 'PASSPORT' | 'DRIVERS_LICENSE' | 'OTHER';
documentNumber: string;
issuingAuthority?: string;
issueDate?: Date;
expiryDate?: Date;
licenseClasses?: string;
licenseIssueDate?: Date;
}
) {
return prisma.identityDocument.create({
data: {
customerId,
...data,
isActive: true,
},
});
}
export async function updateDocument(
id: number,
data: {
type?: 'ID_CARD' | 'PASSPORT' | 'DRIVERS_LICENSE' | 'OTHER';
documentNumber?: string;
issuingAuthority?: string;
issueDate?: Date;
expiryDate?: Date;
licenseClasses?: string;
licenseIssueDate?: Date;
isActive?: boolean;
}
) {
return prisma.identityDocument.update({
where: { id },
data,
});
}
export async function deleteDocument(id: number) {
// Erst Datei-Pfad holen, dann Datei löschen, dann DB-Eintrag löschen
const document = await prisma.identityDocument.findUnique({ where: { id } });
if (document?.documentPath) {
deleteFileIfExists(document.documentPath);
}
return prisma.identityDocument.delete({ where: { id } });
}
// Meter operations
export async function getCustomerMeters(
customerId: number,
showInactive: boolean = false
) {
const where: Record<string, unknown> = { customerId };
if (!showInactive) {
where.isActive = true;
}
return prisma.meter.findMany({
where,
include: {
readings: {
orderBy: { readingDate: 'desc' },
take: 5,
},
},
orderBy: [{ isActive: 'desc' }, { createdAt: 'desc' }],
});
}
export async function createMeter(
customerId: number,
data: {
meterNumber: string;
type: 'ELECTRICITY' | 'GAS';
location?: string;
}
) {
return prisma.meter.create({
data: {
customerId,
...data,
isActive: true,
},
});
}
export async function updateMeter(
id: number,
data: {
meterNumber?: string;
type?: 'ELECTRICITY' | 'GAS';
location?: string;
isActive?: boolean;
}
) {
return prisma.meter.update({
where: { id },
data,
});
}
export async function deleteMeter(id: number) {
return prisma.meter.delete({ where: { id } });
}
export async function addMeterReading(
meterId: number,
data: {
readingDate: Date;
value: number;
unit?: string;
notes?: string;
}
) {
return prisma.meterReading.create({
data: {
meterId,
...data,
},
});
}
export async function getMeterReadings(meterId: number) {
return prisma.meterReading.findMany({
where: { meterId },
orderBy: { readingDate: 'desc' },
});
}
export async function updateMeterReading(
meterId: number,
readingId: number,
data: {
readingDate?: Date;
value?: number;
unit?: string;
notes?: string;
}
) {
// Verify the reading belongs to the meter
const reading = await prisma.meterReading.findFirst({
where: { id: readingId, meterId },
});
if (!reading) {
throw new Error('Zählerstand nicht gefunden');
}
return prisma.meterReading.update({
where: { id: readingId },
data,
});
}
export async function deleteMeterReading(meterId: number, readingId: number) {
// Verify the reading belongs to the meter
const reading = await prisma.meterReading.findFirst({
where: { id: readingId, meterId },
});
if (!reading) {
throw new Error('Zählerstand nicht gefunden');
}
return prisma.meterReading.delete({
where: { id: readingId },
});
}
// ==================== PORTAL SETTINGS ====================
export async function updatePortalSettings(
customerId: number,
data: {
portalEnabled?: boolean;
portalEmail?: string | null;
}
) {
// Wenn Portal deaktiviert wird, Passwort-Hash nicht löschen (für spätere Reaktivierung)
return prisma.customer.update({
where: { id: customerId },
data: {
portalEnabled: data.portalEnabled,
portalEmail: data.portalEmail,
},
select: {
id: true,
portalEnabled: true,
portalEmail: true,
portalLastLogin: true,
},
});
}
export async function getPortalSettings(customerId: number) {
return prisma.customer.findUnique({
where: { id: customerId },
select: {
id: true,
portalEnabled: true,
portalEmail: true,
portalLastLogin: true,
portalPasswordHash: true, // Nur um zu prüfen ob Passwort gesetzt (wird als boolean zurückgegeben)
},
});
}
// ==================== REPRESENTATIVE MANAGEMENT ====================
export async function getCustomerRepresentatives(customerId: number) {
// Holt alle Kunden, die der angegebene Kunde vertreten kann (dieser ist der Vertreter)
return prisma.customerRepresentative.findMany({
where: { representativeId: customerId, isActive: true },
include: {
customer: {
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
type: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
export async function getRepresentedByList(customerId: number) {
// Holt alle Kunden, die den angegebenen Kunden vertreten können
return prisma.customerRepresentative.findMany({
where: { customerId: customerId, isActive: true },
include: {
representative: {
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
type: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
}
export async function addRepresentative(
customerId: number, // Der Kunde, dessen Verträge eingesehen werden dürfen
representativeId: number, // Der Kunde, der einsehen darf
notes?: string
) {
// Prüfen, ob beide Kunden existieren
const [customer, representative] = await Promise.all([
prisma.customer.findUnique({ where: { id: customerId } }),
prisma.customer.findUnique({ where: { id: representativeId } }),
]);
if (!customer) {
throw new Error('Kunde nicht gefunden');
}
if (!representative) {
throw new Error('Vertreter-Kunde nicht gefunden');
}
if (customerId === representativeId) {
throw new Error('Ein Kunde kann sich nicht selbst vertreten');
}
// Prüfen ob der Vertreter ein Portal-Konto hat
if (!representative.portalEnabled) {
throw new Error('Der Vertreter-Kunde muss ein aktiviertes Portal-Konto haben');
}
return prisma.customerRepresentative.upsert({
where: {
customerId_representativeId: { customerId, representativeId },
},
create: {
customerId,
representativeId,
notes,
isActive: true,
},
update: {
isActive: true,
notes,
},
include: {
representative: {
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
type: true,
},
},
},
});
}
export async function removeRepresentative(customerId: number, representativeId: number) {
// Anstatt zu löschen, setzen wir isActive auf false
return prisma.customerRepresentative.update({
where: {
customerId_representativeId: { customerId, representativeId },
},
data: { isActive: false },
});
}
export async function searchCustomersForRepresentative(search: string, excludeCustomerId: number) {
// Sucht Kunden, die als Vertreter hinzugefügt werden können
// Nur Kunden mit aktiviertem Portal
return prisma.customer.findMany({
where: {
id: { not: excludeCustomerId },
portalEnabled: true,
OR: [
{ firstName: { contains: search } },
{ lastName: { contains: search } },
{ companyName: { contains: search } },
{ customerNumber: { contains: search } },
],
},
select: {
id: true,
customerNumber: true,
firstName: true,
lastName: true,
companyName: true,
type: true,
},
take: 10,
});
}
@@ -0,0 +1,378 @@
// ==================== EMAIL PROVIDER SERVICE ====================
import { PrismaClient } from '@prisma/client';
import { decrypt } from '../../utils/encryption.js';
import {
IEmailProvider,
EmailProviderConfig,
EmailExistsResult,
EmailOperationResult,
CreateEmailParams,
} from './types.js';
import { PleskEmailProvider } from './pleskProvider.js';
const prisma = new PrismaClient();
// Factory-Funktion um den richtigen Provider zu erstellen
function createProvider(config: EmailProviderConfig): IEmailProvider {
switch (config.type) {
case 'PLESK':
return new PleskEmailProvider(config);
case 'CPANEL':
// TODO: cPanel Provider implementieren
throw new Error('cPanel Provider noch nicht implementiert');
case 'DIRECTADMIN':
// TODO: DirectAdmin Provider implementieren
throw new Error('DirectAdmin Provider noch nicht implementiert');
default:
throw new Error(`Unbekannter Provider-Typ: ${config.type}`);
}
}
// ==================== CONFIG CRUD ====================
export async function getAllProviderConfigs() {
return prisma.emailProviderConfig.findMany({
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
});
}
export async function getProviderConfigById(id: number) {
return prisma.emailProviderConfig.findUnique({
where: { id },
});
}
export async function getDefaultProviderConfig() {
return prisma.emailProviderConfig.findFirst({
where: { isActive: true, isDefault: true },
});
}
export async function getActiveProviderConfig() {
// Erst Default-Provider versuchen, dann irgendeinen aktiven
const defaultProvider = await getDefaultProviderConfig();
if (defaultProvider) return defaultProvider;
return prisma.emailProviderConfig.findFirst({
where: { isActive: true },
});
}
export interface CreateProviderConfigData {
name: string;
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
apiUrl: string;
apiKey?: string;
username?: string;
password?: string;
domain: string;
defaultForwardEmail?: string;
isActive?: boolean;
isDefault?: boolean;
}
export async function createProviderConfig(data: CreateProviderConfigData) {
// Falls isDefault=true, alle anderen auf false setzen
if (data.isDefault) {
await prisma.emailProviderConfig.updateMany({
where: { isDefault: true },
data: { isDefault: false },
});
}
// Passwort verschlüsseln falls vorhanden
const { encrypt } = await import('../../utils/encryption.js');
const passwordEncrypted = data.password ? encrypt(data.password) : null;
return prisma.emailProviderConfig.create({
data: {
name: data.name,
type: data.type,
apiUrl: data.apiUrl,
apiKey: data.apiKey || null,
username: data.username || null,
passwordEncrypted,
domain: data.domain,
defaultForwardEmail: data.defaultForwardEmail || null,
isActive: data.isActive ?? true,
isDefault: data.isDefault ?? false,
},
});
}
export async function updateProviderConfig(
id: number,
data: Partial<CreateProviderConfigData>
) {
// Falls isDefault=true, alle anderen auf false setzen
if (data.isDefault) {
await prisma.emailProviderConfig.updateMany({
where: { isDefault: true, id: { not: id } },
data: { isDefault: false },
});
}
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.type !== undefined) updateData.type = data.type;
if (data.apiUrl !== undefined) updateData.apiUrl = data.apiUrl;
if (data.apiKey !== undefined) updateData.apiKey = data.apiKey || null;
if (data.username !== undefined) updateData.username = data.username || null;
if (data.domain !== undefined) updateData.domain = data.domain;
if (data.defaultForwardEmail !== undefined)
updateData.defaultForwardEmail = data.defaultForwardEmail || null;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
// Passwort-Logik:
// - Wenn neues Passwort übergeben → verschlüsseln und speichern
// - Wenn Benutzername gelöscht wird → Passwort auch löschen (gehören zusammen)
if (data.password) {
const { encrypt } = await import('../../utils/encryption.js');
updateData.passwordEncrypted = encrypt(data.password);
} else if (data.username !== undefined && !data.username) {
// Benutzername wird gelöscht → Passwort auch löschen
updateData.passwordEncrypted = null;
}
return prisma.emailProviderConfig.update({
where: { id },
data: updateData,
});
}
export async function deleteProviderConfig(id: number) {
return prisma.emailProviderConfig.delete({
where: { id },
});
}
// ==================== EMAIL OPERATIONS ====================
// Provider-Instanz aus DB-Config erstellen
async function getProviderInstance(): Promise<IEmailProvider> {
const dbConfig = await getActiveProviderConfig();
if (!dbConfig) {
throw new Error('Kein aktiver Email-Provider konfiguriert');
}
// Passwort entschlüsseln
let password: string | undefined;
if (dbConfig.passwordEncrypted) {
try {
password = decrypt(dbConfig.passwordEncrypted);
} catch {
console.error('Konnte Passwort nicht entschlüsseln');
}
}
const config: EmailProviderConfig = {
id: dbConfig.id,
name: dbConfig.name,
type: dbConfig.type as 'PLESK' | 'CPANEL' | 'DIRECTADMIN',
apiUrl: dbConfig.apiUrl,
apiKey: dbConfig.apiKey || undefined,
username: dbConfig.username || undefined,
password,
domain: dbConfig.domain,
defaultForwardEmail: dbConfig.defaultForwardEmail || undefined,
isActive: dbConfig.isActive,
isDefault: dbConfig.isDefault,
};
return createProvider(config);
}
// Prüfen ob eine E-Mail existiert
export async function checkEmailExists(localPart: string): Promise<EmailExistsResult> {
try {
const provider = await getProviderInstance();
return provider.emailExists(localPart);
} catch (error) {
console.error('checkEmailExists error:', error);
return { exists: false };
}
}
// E-Mail erstellen mit Weiterleitungen
export async function provisionEmail(
localPart: string,
customerEmail: string
): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
const config = await getActiveProviderConfig();
// Weiterleitungsziele zusammenstellen
const forwardTargets: string[] = [customerEmail];
// Unsere eigene Weiterleitungsadresse hinzufügen falls konfiguriert
if (config?.defaultForwardEmail) {
forwardTargets.push(config.defaultForwardEmail);
}
// Prüfen ob existiert
const exists = await provider.emailExists(localPart);
if (exists.exists) {
return {
success: true,
message: `E-Mail ${exists.email} existiert bereits`,
};
}
// Erstellen
const result = await provider.createEmail({
localPart,
forwardTargets,
});
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// E-Mail löschen
export async function deprovisionEmail(localPart: string): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
return provider.deleteEmail(localPart);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// E-Mail umbenennen
export async function renameProvisionedEmail(
oldLocalPart: string,
newLocalPart: string
): Promise<EmailOperationResult> {
try {
const provider = await getProviderInstance();
return provider.renameEmail({ oldLocalPart, newLocalPart });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
// Domain aus aktivem Provider holen
export async function getProviderDomain(): Promise<string | null> {
const config = await getActiveProviderConfig();
return config?.domain || null;
}
// Provider-Instanz aus übergebener Config erstellen (für Tests mit ungespeicherten Daten)
function createProviderFromFormData(data: {
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
apiUrl: string;
apiKey?: string;
username?: string;
password?: string;
domain: string;
}): IEmailProvider {
const config: EmailProviderConfig = {
id: 0,
name: 'Test',
type: data.type,
apiUrl: data.apiUrl,
apiKey: data.apiKey,
username: data.username,
password: data.password,
domain: data.domain,
isActive: true,
isDefault: false,
};
return createProvider(config);
}
// Provider-Instanz aus DB-Config per ID erstellen
async function getProviderInstanceById(id: number): Promise<IEmailProvider> {
const dbConfig = await getProviderConfigById(id);
if (!dbConfig) {
throw new Error('Email-Provider nicht gefunden');
}
// Passwort entschlüsseln
let password: string | undefined;
if (dbConfig.passwordEncrypted) {
try {
password = decrypt(dbConfig.passwordEncrypted);
} catch {
console.error('Konnte Passwort nicht entschlüsseln');
}
}
const config: EmailProviderConfig = {
id: dbConfig.id,
name: dbConfig.name,
type: dbConfig.type as 'PLESK' | 'CPANEL' | 'DIRECTADMIN',
apiUrl: dbConfig.apiUrl,
apiKey: dbConfig.apiKey || undefined,
username: dbConfig.username || undefined,
password,
domain: dbConfig.domain,
defaultForwardEmail: dbConfig.defaultForwardEmail || undefined,
isActive: dbConfig.isActive,
isDefault: dbConfig.isDefault,
};
return createProvider(config);
}
// Provider-Verbindung testen (mit ID, Formulardaten oder Default-Provider)
export async function testProviderConnection(options?: {
id?: number;
testData?: {
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
apiUrl: string;
apiKey?: string;
username?: string;
password?: string;
domain: string;
};
}): Promise<EmailOperationResult> {
try {
let provider: IEmailProvider;
if (options?.testData) {
// Mit übergebenen Daten testen (z.B. aus Modal beim Neuanlegen)
provider = createProviderFromFormData(options.testData);
} else if (options?.id) {
// Gespeicherten Provider per ID testen
provider = await getProviderInstanceById(options.id);
} else {
// Default-Provider testen
provider = await getProviderInstance();
}
// Expliziter Verbindungstest (wirft Fehler bei Auth-Problemen)
await provider.testConnection();
return {
success: true,
message: 'Verbindung zum Email-Provider erfolgreich',
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
return {
success: false,
error: errorMessage,
};
}
}
@@ -0,0 +1,5 @@
// ==================== EMAIL PROVIDER EXPORTS ====================
export * from './types.js';
export * from './emailProviderService.js';
export { PleskEmailProvider } from './pleskProvider.js';
@@ -0,0 +1,348 @@
// ==================== PLESK EMAIL PROVIDER ====================
import { Agent, fetch as undiciFetch } from 'undici';
import {
IEmailProvider,
EmailProviderConfig,
EmailExistsResult,
EmailOperationResult,
CreateEmailParams,
RenameEmailParams,
} from './types.js';
// Undici-Agent der selbstsignierte Zertifikate akzeptiert
// Mit Timeouts und Connection-Limits um Probleme zu vermeiden
const httpsAgent = new Agent({
connect: {
rejectUnauthorized: false,
timeout: 10000, // 10 Sekunden Connect-Timeout
},
bodyTimeout: 30000, // 30 Sekunden für Response-Body
headersTimeout: 30000, // 30 Sekunden für Headers
keepAliveTimeout: 1000, // Connections nach 1 Sekunde schließen
keepAliveMaxTimeout: 5000, // Maximal 5 Sekunden Keep-Alive
connections: 1, // Nur eine Connection gleichzeitig pro Host
pipelining: 1, // Kein Pipelining
});
export class PleskEmailProvider implements IEmailProvider {
readonly type = 'PLESK';
private config: EmailProviderConfig;
constructor(config: EmailProviderConfig) {
this.config = config;
}
// Basis-URL für API-Requests
private get baseUrl(): string {
// Entferne trailing slash falls vorhanden
return this.config.apiUrl.replace(/\/$/, '');
}
// HTTP-Request an Plesk API senden
private async request<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
endpoint: string,
data?: Record<string, unknown>
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
};
// Authentifizierung: API-Key hat Priorität, sonst Basic Auth
if (this.config.apiKey) {
// Nur API-Key verwenden (ohne Basic Auth)
headers['X-API-Key'] = this.config.apiKey;
} else if (this.config.username && this.config.password) {
// Basic Auth nur wenn kein API-Key
const authHeader = Buffer.from(
`${this.config.username}:${this.config.password}`
).toString('base64');
headers['Authorization'] = `Basic ${authHeader}`;
} else {
// Keine Authentifizierung vorhanden
throw new Error('Keine Zugangsdaten angegeben - bitte API-Key oder Benutzername/Passwort eingeben');
}
const options: Parameters<typeof undiciFetch>[1] = {
method,
headers,
dispatcher: httpsAgent,
};
if (data && (method === 'POST' || method === 'PUT')) {
options.body = JSON.stringify(data);
}
try {
const response = await undiciFetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Plesk API Fehler: ${response.status} - ${errorText}`);
}
// Leere Response bei DELETE
if (response.status === 204) {
return {} as T;
}
return await response.json() as T;
} catch (error) {
// Verbesserte Fehlermeldungen für häufige Probleme
if (error instanceof Error) {
const msg = error.message.toLowerCase();
// Netzwerkfehler
if (msg.includes('econnrefused')) {
throw new Error(`Server nicht erreichbar unter ${this.baseUrl} - Ist der Server gestartet?`);
}
if (msg.includes('enotfound') || msg.includes('getaddrinfo')) {
throw new Error(`Server-Adresse nicht gefunden: ${this.baseUrl} - Bitte URL prüfen`);
}
if (msg.includes('etimedout') || msg.includes('timeout')) {
throw new Error(`Zeitüberschreitung bei Verbindung zu ${this.baseUrl}`);
}
if (msg.includes('econnreset')) {
throw new Error(`Verbindung wurde vom Server abgebrochen`);
}
// SSL/TLS Fehler
if (msg.includes('cert') || msg.includes('ssl') || msg.includes('tls') || msg.includes('unable_to_verify')) {
throw new Error(`SSL-Zertifikatsfehler - Selbstsigniertes Zertifikat wird nicht akzeptiert`);
}
// fetch failed ist meist ein Netzwerk/SSL Problem
if (msg.includes('fetch failed')) {
throw new Error(`Verbindung fehlgeschlagen zu ${this.baseUrl} - Bitte prüfen: Server erreichbar? HTTPS-Port korrekt?`);
}
}
console.error('Plesk API Request failed:', error);
throw error;
}
}
async testConnection(): Promise<void> {
// Versuche Server-Info abzurufen - wirft Fehler bei Auth-Problemen
try {
await this.request('GET', '/api/v2/server');
} catch (error) {
if (error instanceof Error) {
// Verbesserte Fehlermeldung
if (error.message.includes('401')) {
throw new Error('Authentifizierung fehlgeschlagen - Benutzername/Passwort oder API-Key prüfen');
}
if (error.message.includes('403')) {
throw new Error('Zugriff verweigert - Berechtigungen prüfen');
}
// Andere Fehler wurden schon in request() übersetzt
}
throw error;
}
}
async emailExists(localPart: string): Promise<EmailExistsResult> {
const email = `${localPart}@${this.config.domain}`;
try {
// Plesk CLI API: Mail-Info abfragen
const result = await this.request<{ code: number; stdout: string; stderr: string }>(
'POST',
'/api/v2/cli/mail/call',
{ params: ['--info', email] }
);
// Debug: Response-Struktur loggen
console.log('Plesk emailExists response:', JSON.stringify(result, null, 2));
// Plesk gibt code=0 bei Erfolg, code!=0 bei Fehler
// stderr enthält Fehlermeldung wenn Mail nicht existiert
const hasError = result.code !== 0 ||
result.stderr?.toLowerCase().includes('not found') ||
result.stderr?.toLowerCase().includes('does not exist') ||
result.stderr?.toLowerCase().includes('unable to find') ||
result.stderr?.toLowerCase().includes('no such');
if (hasError) {
return { exists: false };
}
// stdout sollte die Mail-Infos enthalten
const exists = result.stdout?.toLowerCase().includes(localPart.toLowerCase());
return {
exists,
email: exists ? email : undefined,
};
} catch (error) {
// HTTP-Fehler oder Netzwerkfehler
if (error instanceof Error) {
const msg = error.message.toLowerCase();
// "not found" = Mail gibt es nicht
if (msg.includes('not found') || msg.includes('does not exist') || msg.includes('unable to find')) {
return { exists: false };
}
}
console.error('Plesk emailExists error:', error);
return { exists: false };
}
}
async createEmail(params: CreateEmailParams): Promise<EmailOperationResult> {
const { localPart, forwardTargets } = params;
const email = `${localPart}@${this.config.domain}`;
try {
// Prüfen ob schon existiert
const exists = await this.emailExists(localPart);
if (exists.exists) {
return {
success: false,
error: `E-Mail ${email} existiert bereits`,
};
}
// Plesk CLI API: Mail-Account mit Weiterleitung erstellen
// Verwendet den CLI-Wrapper unter /api/v2/cli/mail/call
// Format für -forwarding-addresses: "add:email1,email2" oder "set:email1,email2"
await this.request('POST', '/api/v2/cli/mail/call', {
params: [
'--create', email,
'-forwarding', 'true',
'-forwarding-addresses', `add:${forwardTargets.join(',')}`,
'-mailbox', 'false',
],
});
return {
success: true,
message: `E-Mail ${email} erfolgreich erstellt mit Weiterleitung an: ${forwardTargets.join(', ')}`,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.error('Plesk createEmail error:', error);
return {
success: false,
error: `Fehler beim Erstellen der E-Mail: ${errorMessage}`,
};
}
}
async deleteEmail(localPart: string): Promise<EmailOperationResult> {
const email = `${localPart}@${this.config.domain}`;
try {
// Prüfen ob Mail existiert
const exists = await this.emailExists(localPart);
if (!exists.exists) {
return {
success: false,
error: `E-Mail ${email} nicht gefunden`,
};
}
// Plesk CLI API: Mail-Account löschen
await this.request('POST', '/api/v2/cli/mail/call', {
params: ['--remove', email],
});
return {
success: true,
message: `E-Mail ${email} erfolgreich gelöscht`,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.error('Plesk deleteEmail error:', error);
return {
success: false,
error: `Fehler beim Löschen der E-Mail: ${errorMessage}`,
};
}
}
async renameEmail(params: RenameEmailParams): Promise<EmailOperationResult> {
const { oldLocalPart, newLocalPart } = params;
const oldEmail = `${oldLocalPart}@${this.config.domain}`;
const newEmail = `${newLocalPart}@${this.config.domain}`;
try {
// Prüfen ob alte Mail existiert
const oldExists = await this.emailExists(oldLocalPart);
if (!oldExists.exists) {
return {
success: false,
error: `E-Mail ${oldEmail} nicht gefunden`,
};
}
// Prüfen ob neue Adresse schon existiert
const newExists = await this.emailExists(newLocalPart);
if (newExists.exists) {
return {
success: false,
error: `E-Mail ${newEmail} existiert bereits`,
};
}
// Plesk CLI API: Mail-Account umbenennen
await this.request('POST', '/api/v2/cli/mail/call', {
params: ['--rename', oldEmail, '-new-name', newLocalPart],
});
return {
success: true,
message: `E-Mail erfolgreich umbenannt von ${oldEmail} zu ${newEmail}`,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.error('Plesk renameEmail error:', error);
return {
success: false,
error: `Fehler beim Umbenennen der E-Mail: ${errorMessage}`,
};
}
}
async updateForwardTargets(
localPart: string,
targets: string[]
): Promise<EmailOperationResult> {
const email = `${localPart}@${this.config.domain}`;
try {
// Prüfen ob Mail existiert
const exists = await this.emailExists(localPart);
if (!exists.exists) {
return {
success: false,
error: `E-Mail ${email} nicht gefunden`,
};
}
// Plesk CLI API: Weiterleitungsziele aktualisieren
// Format für -forwarding-addresses: "set:email1,email2" ersetzt alle Adressen
await this.request('POST', '/api/v2/cli/mail/call', {
params: [
'--update', email,
'-forwarding', 'true',
'-forwarding-addresses', `set:${targets.join(',')}`,
],
});
return {
success: true,
message: `Weiterleitungen für ${email} aktualisiert: ${targets.join(', ')}`,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unbekannter Fehler';
console.error('Plesk updateForwardTargets error:', error);
return {
success: false,
error: `Fehler beim Aktualisieren der Weiterleitungen: ${errorMessage}`,
};
}
}
}
@@ -0,0 +1,65 @@
// ==================== EMAIL PROVIDER TYPES ====================
export interface EmailForwardTarget {
email: string;
}
export interface CreateEmailParams {
localPart: string; // z.B. "max.mustermann"
forwardTargets: string[]; // Weiterleitungsziele
}
export interface RenameEmailParams {
oldLocalPart: string;
newLocalPart: string;
}
export interface EmailExistsResult {
exists: boolean;
email?: string;
}
export interface EmailOperationResult {
success: boolean;
message?: string;
error?: string;
}
// Interface das alle Email-Provider implementieren müssen
export interface IEmailProvider {
// Provider-Typ (z.B. 'PLESK', 'CPANEL')
readonly type: string;
// Testet die Verbindung zum Provider (wirft Fehler bei Fehlschlag)
testConnection(): Promise<void>;
// Prüft ob eine E-Mail-Adresse existiert
emailExists(localPart: string): Promise<EmailExistsResult>;
// Erstellt eine neue E-Mail-Weiterleitung
createEmail(params: CreateEmailParams): Promise<EmailOperationResult>;
// Löscht eine E-Mail-Adresse
deleteEmail(localPart: string): Promise<EmailOperationResult>;
// Benennt eine E-Mail-Adresse um
renameEmail(params: RenameEmailParams): Promise<EmailOperationResult>;
// Aktualisiert die Weiterleitungsziele
updateForwardTargets(localPart: string, targets: string[]): Promise<EmailOperationResult>;
}
// Konfiguration für Provider (aus DB)
export interface EmailProviderConfig {
id: number;
name: string;
type: 'PLESK' | 'CPANEL' | 'DIRECTADMIN';
apiUrl: string;
apiKey?: string;
username?: string;
password?: string; // Entschlüsselt
domain: string;
defaultForwardEmail?: string;
isActive: boolean;
isDefault: boolean;
}
+63
View File
@@ -0,0 +1,63 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function getAllPlatforms(includeInactive = false) {
const where = includeInactive ? {} : { isActive: true };
return prisma.salesPlatform.findMany({
where,
orderBy: { name: 'asc' },
});
}
export async function getPlatformById(id: number) {
return prisma.salesPlatform.findUnique({
where: { id },
include: {
_count: {
select: { contracts: true },
},
},
});
}
export async function createPlatform(data: {
name: string;
contactInfo?: string;
}) {
return prisma.salesPlatform.create({
data: {
...data,
isActive: true,
},
});
}
export async function updatePlatform(
id: number,
data: {
name?: string;
contactInfo?: string;
isActive?: boolean;
}
) {
return prisma.salesPlatform.update({
where: { id },
data,
});
}
export async function deletePlatform(id: number) {
// Check if platform is used by any contracts
const count = await prisma.contract.count({
where: { salesPlatformId: id },
});
if (count > 0) {
throw new Error(
`Plattform kann nicht gelöscht werden, da sie von ${count} Verträgen verwendet wird`
);
}
return prisma.salesPlatform.delete({ where: { id } });
}
+79
View File
@@ -0,0 +1,79 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function getAllProviders(includeInactive = false) {
const where = includeInactive ? {} : { isActive: true };
return prisma.provider.findMany({
where,
orderBy: { name: 'asc' },
include: {
tariffs: {
where: includeInactive ? {} : { isActive: true },
orderBy: { name: 'asc' },
},
_count: {
select: { contracts: true, tariffs: true },
},
},
});
}
export async function getProviderById(id: number) {
return prisma.provider.findUnique({
where: { id },
include: {
tariffs: {
orderBy: { name: 'asc' },
},
_count: {
select: { contracts: true },
},
},
});
}
export async function createProvider(data: {
name: string;
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
}) {
return prisma.provider.create({
data: {
...data,
isActive: true,
},
});
}
export async function updateProvider(
id: number,
data: {
name?: string;
portalUrl?: string;
usernameFieldName?: string;
passwordFieldName?: string;
isActive?: boolean;
}
) {
return prisma.provider.update({
where: { id },
data,
});
}
export async function deleteProvider(id: number) {
// Check if provider is used by any contracts
const count = await prisma.contract.count({
where: { providerId: id },
});
if (count > 0) {
throw new Error(
`Anbieter kann nicht gelöscht werden, da er von ${count} Verträgen verwendet wird`
);
}
return prisma.provider.delete({ where: { id } });
}
@@ -0,0 +1,53 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function getEmailsByCustomerId(customerId: number, includeInactive = false) {
const where: Record<string, unknown> = { customerId };
if (!includeInactive) {
where.isActive = true;
}
return prisma.stressfreiEmail.findMany({
where,
orderBy: { createdAt: 'desc' },
});
}
export async function getEmailById(id: number) {
return prisma.stressfreiEmail.findUnique({
where: { id },
});
}
export async function createEmail(data: {
customerId: number;
email: string;
platform?: string;
notes?: string;
}) {
return prisma.stressfreiEmail.create({
data: {
...data,
isActive: true,
},
});
}
export async function updateEmail(
id: number,
data: {
email?: string;
platform?: string;
notes?: string;
isActive?: boolean;
}
) {
return prisma.stressfreiEmail.update({
where: { id },
data,
});
}
export async function deleteEmail(id: number) {
return prisma.stressfreiEmail.delete({ where: { id } });
}
+71
View File
@@ -0,0 +1,71 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function getTariffsByProvider(providerId: number, includeInactive = false) {
const where: { providerId: number; isActive?: boolean } = { providerId };
if (!includeInactive) {
where.isActive = true;
}
return prisma.tariff.findMany({
where,
orderBy: { name: 'asc' },
include: {
_count: {
select: { contracts: true },
},
},
});
}
export async function getTariffById(id: number) {
return prisma.tariff.findUnique({
where: { id },
include: {
provider: true,
_count: {
select: { contracts: true },
},
},
});
}
export async function createTariff(data: {
providerId: number;
name: string;
}) {
return prisma.tariff.create({
data: {
...data,
isActive: true,
},
});
}
export async function updateTariff(
id: number,
data: {
name?: string;
isActive?: boolean;
}
) {
return prisma.tariff.update({
where: { id },
data,
});
}
export async function deleteTariff(id: number) {
// Check if tariff is used by any contracts
const count = await prisma.contract.count({
where: { tariffId: id },
});
if (count > 0) {
throw new Error(
`Tarif kann nicht gelöscht werden, da er von ${count} Verträgen verwendet wird`
);
}
return prisma.tariff.delete({ where: { id } });
}
+504
View File
@@ -0,0 +1,504 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
import { paginate, buildPaginationResponse } from '../utils/helpers.js';
const prisma = new PrismaClient();
export interface UserFilters {
search?: string;
isActive?: boolean;
roleId?: number;
page?: number;
limit?: number;
}
export async function getAllUsers(filters: UserFilters) {
const { search, isActive, roleId, page = 1, limit = 20 } = filters;
const { skip, take } = paginate(page, limit);
const where: Record<string, unknown> = {};
if (isActive !== undefined) {
where.isActive = isActive;
}
if (roleId) {
where.roles = { some: { roleId } };
}
if (search) {
where.OR = [
{ email: { contains: search } },
{ firstName: { contains: search } },
{ lastName: { contains: search } },
];
}
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
isActive: true,
customerId: true,
createdAt: true,
roles: {
include: {
role: {
include: {
permissions: true,
},
},
},
},
},
}),
prisma.user.count({ where }),
]);
// Get Developer role ID
const developerRole = await prisma.role.findFirst({
where: { name: 'Developer' },
});
return {
users: users.map((u) => {
// Check if user has developer role assigned
const hasDeveloperAccess = developerRole
? u.roles.some((ur) => ur.roleId === developerRole.id)
: false;
return {
...u,
roles: u.roles.map((r) => r.role),
hasDeveloperAccess,
};
}),
pagination: buildPaginationResponse(page, limit, total),
};
}
export async function getUserById(id: number) {
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
email: true,
firstName: true,
lastName: true,
isActive: true,
customerId: true,
createdAt: true,
updatedAt: true,
roles: {
include: {
role: {
include: {
permissions: {
include: { permission: true },
},
},
},
},
},
},
});
if (!user) return null;
const permissions = new Set<string>();
for (const userRole of user.roles) {
for (const rolePerm of userRole.role.permissions) {
permissions.add(
`${rolePerm.permission.resource}:${rolePerm.permission.action}`
);
}
}
return {
...user,
roles: user.roles.map((r) => r.role),
permissions: Array.from(permissions),
};
}
export async function createUser(data: {
email: string;
password: string;
firstName: string;
lastName: string;
roleIds: number[];
customerId?: number;
}) {
const hashedPassword = await bcrypt.hash(data.password, 10);
return prisma.user.create({
data: {
email: data.email,
password: hashedPassword,
firstName: data.firstName,
lastName: data.lastName,
customerId: data.customerId,
roles: {
create: data.roleIds.map((roleId) => ({ roleId })),
},
},
select: {
id: true,
email: true,
firstName: true,
lastName: true,
isActive: true,
customerId: true,
roles: {
include: { role: true },
},
},
});
}
export async function updateUser(
id: number,
data: {
email?: string;
password?: string;
firstName?: string;
lastName?: string;
isActive?: boolean;
roleIds?: number[];
customerId?: number;
hasDeveloperAccess?: boolean;
}
) {
const { roleIds, password, hasDeveloperAccess, ...userData } = data;
// Check if this would remove the last admin
const isBeingDeactivated = userData.isActive === false;
const rolesAreBeingChanged = roleIds !== undefined;
if (isBeingDeactivated || rolesAreBeingChanged) {
// Check if user currently has admin permissions
const currentUser = await prisma.user.findUnique({
where: { id },
include: {
roles: {
include: {
role: {
include: {
permissions: {
include: { permission: true },
},
},
},
},
},
},
});
const isCurrentlyAdmin = currentUser?.roles.some((ur) =>
ur.role.permissions.some(
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
)
);
if (isCurrentlyAdmin) {
// Check if user will still be admin after role change
let willStillBeAdmin = false;
if (rolesAreBeingChanged) {
const newRoles = await prisma.role.findMany({
where: { id: { in: roleIds } },
include: {
permissions: {
include: { permission: true },
},
},
});
willStillBeAdmin = newRoles.some((role) =>
role.permissions.some(
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
)
);
} else {
willStillBeAdmin = true; // Roles not being changed
}
// If user is losing admin status or being deactivated, check for other admins
if (!willStillBeAdmin || isBeingDeactivated) {
const otherAdminCount = await prisma.user.count({
where: {
id: { not: id },
isActive: true,
roles: {
some: {
role: {
permissions: {
some: {
permission: {
resource: 'users',
action: 'delete',
},
},
},
},
},
},
},
});
if (otherAdminCount === 0) {
if (isBeingDeactivated) {
throw new Error(
'Dieser Benutzer ist der letzte Administrator und kann nicht deaktiviert werden'
);
} else {
throw new Error(
'Die Admin-Rolle kann nicht entfernt werden, da dies der letzte Administrator ist'
);
}
}
}
}
}
// Hash password if provided
if (password) {
(userData as Record<string, unknown>).password = await bcrypt.hash(password, 10);
}
// Update user
await prisma.user.update({
where: { id },
data: userData,
});
// Update roles if provided
if (roleIds) {
await prisma.userRole.deleteMany({ where: { userId: id } });
await prisma.userRole.createMany({
data: roleIds.map((roleId) => ({ userId: id, roleId })),
});
}
// Handle developer access
console.log('updateUser - hasDeveloperAccess:', hasDeveloperAccess);
if (hasDeveloperAccess !== undefined) {
await setUserDeveloperAccess(id, hasDeveloperAccess);
}
return getUserById(id);
}
// Helper to set developer access for a user
async function setUserDeveloperAccess(userId: number, enabled: boolean) {
console.log('setUserDeveloperAccess called - userId:', userId, 'enabled:', enabled);
// Get or create developer:access permission
let developerPerm = await prisma.permission.findFirst({
where: { resource: 'developer', action: 'access' },
});
if (!developerPerm) {
developerPerm = await prisma.permission.create({
data: { resource: 'developer', action: 'access' },
});
}
// Get or create Developer role
let developerRole = await prisma.role.findFirst({
where: { name: 'Developer' },
});
if (!developerRole) {
developerRole = await prisma.role.create({
data: {
name: 'Developer',
description: 'Entwicklerzugriff auf Datenbanktools',
permissions: {
create: [{ permissionId: developerPerm.id }],
},
},
});
}
// Check if user already has Developer role
const hasRole = await prisma.userRole.findFirst({
where: { userId, roleId: developerRole.id },
});
console.log('setUserDeveloperAccess - developerRole.id:', developerRole.id, 'hasRole:', hasRole);
if (enabled && !hasRole) {
// Add Developer role
console.log('Adding Developer role');
await prisma.userRole.create({
data: { userId, roleId: developerRole.id },
});
} else if (!enabled && hasRole) {
// Remove Developer role
console.log('Removing Developer role');
await prisma.userRole.delete({
where: { userId_roleId: { userId, roleId: developerRole.id } },
});
} else {
console.log('No action needed - enabled:', enabled, 'hasRole:', !!hasRole);
}
}
export async function deleteUser(id: number) {
// Check if user is an admin
const user = await prisma.user.findUnique({
where: { id },
include: {
roles: {
include: {
role: {
include: {
permissions: {
include: { permission: true },
},
},
},
},
},
},
});
if (!user) {
throw new Error('Benutzer nicht gefunden');
}
// Check if user has admin permissions (users:delete means admin)
const isAdmin = user.roles.some((ur) =>
ur.role.permissions.some(
(rp) => rp.permission.resource === 'users' && rp.permission.action === 'delete'
)
);
if (isAdmin) {
// Count other admins (users with users:delete permission)
const adminCount = await prisma.user.count({
where: {
id: { not: id },
isActive: true,
roles: {
some: {
role: {
permissions: {
some: {
permission: {
resource: 'users',
action: 'delete',
},
},
},
},
},
},
},
});
if (adminCount === 0) {
throw new Error(
'Dieser Benutzer ist der letzte Administrator und kann nicht gelöscht werden'
);
}
}
return prisma.user.delete({ where: { id } });
}
// Role operations
export async function getAllRoles() {
return prisma.role.findMany({
include: {
permissions: {
include: { permission: true },
},
_count: {
select: { users: true },
},
},
orderBy: { name: 'asc' },
});
}
export async function getRoleById(id: number) {
return prisma.role.findUnique({
where: { id },
include: {
permissions: {
include: { permission: true },
},
},
});
}
export async function createRole(data: {
name: string;
description?: string;
permissionIds: number[];
}) {
return prisma.role.create({
data: {
name: data.name,
description: data.description,
permissions: {
create: data.permissionIds.map((permissionId) => ({ permissionId })),
},
},
include: {
permissions: {
include: { permission: true },
},
},
});
}
export async function updateRole(
id: number,
data: {
name?: string;
description?: string;
permissionIds?: number[];
}
) {
const { permissionIds, ...roleData } = data;
await prisma.role.update({
where: { id },
data: roleData,
});
if (permissionIds) {
await prisma.rolePermission.deleteMany({ where: { roleId: id } });
await prisma.rolePermission.createMany({
data: permissionIds.map((permissionId) => ({ roleId: id, permissionId })),
});
}
return getRoleById(id);
}
export async function deleteRole(id: number) {
// Check if role is assigned to any users
const count = await prisma.userRole.count({ where: { roleId: id } });
if (count > 0) {
throw new Error(
`Rolle kann nicht gelöscht werden, da sie ${count} Benutzern zugewiesen ist`
);
}
return prisma.role.delete({ where: { id } });
}
// Permission operations
export async function getAllPermissions() {
return prisma.permission.findMany({
orderBy: [{ resource: 'asc' }, { action: 'asc' }],
});
}