Compare commits
6 Commits
2699654631
...
c593700943
| Author | SHA1 | Date | |
|---|---|---|---|
| c593700943 | |||
| b554c8e436 | |||
| 10ddd5118c | |||
| 0a7740f27a | |||
| 334c40803f | |||
| 8582769f92 |
@@ -11,6 +11,7 @@ import { decrypt } from '../utils/encryption.js';
|
||||
import { ApiResponse } from '../types/index.js';
|
||||
import { getCustomerTargets, getContractTargets, getIdentityDocumentTargets, getBankCardTargets, documentTargets } from '../config/documentTargets.config.js';
|
||||
import { generateEmailPdf } from '../services/pdfService.js';
|
||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
||||
import { DocumentType } from '@prisma/client';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import path from 'path';
|
||||
@@ -256,12 +257,30 @@ export async function syncAccount(req: Request, res: Response): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Security: verhindert Header-Injection via CRLF in E-Mail-Feldern.
|
||||
// nodemailer prüft das zwar auch selbst, aber besser vor dem Versand
|
||||
// einen sauberen 400er zurückgeben als einen unklaren SMTP-Fehler.
|
||||
function hasCRLF(value: unknown): boolean {
|
||||
if (typeof value === 'string') return /[\r\n]/.test(value);
|
||||
if (Array.isArray(value)) return value.some(hasCRLF);
|
||||
return false;
|
||||
}
|
||||
|
||||
// E-Mail senden
|
||||
export async function sendEmailFromAccount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const stressfreiEmailId = parseInt(req.params.id);
|
||||
const { to, cc, subject, text, html, inReplyTo, references, attachments, contractId } = req.body;
|
||||
|
||||
// Header-Injection (CRLF) in Empfänger/Betreff ablehnen
|
||||
if (hasCRLF(to) || hasCRLF(cc) || hasCRLF(subject) || hasCRLF(inReplyTo) || hasCRLF(references)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Ungültige Zeichen in E-Mail-Feldern (Zeilenumbrüche nicht erlaubt)',
|
||||
} as ApiResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// StressfreiEmail laden
|
||||
const stressfreiEmail = await stressfreiEmailService.getEmailWithMailboxById(stressfreiEmailId);
|
||||
|
||||
@@ -514,10 +533,26 @@ export async function downloadAttachment(req: AuthRequest, res: Response): Promi
|
||||
return;
|
||||
}
|
||||
|
||||
// Datei senden - inline (öffnen) oder attachment (download)
|
||||
const disposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
||||
res.setHeader('Content-Type', attachment.contentType);
|
||||
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`);
|
||||
// Security: Content-Type aus IMAP kommt vom Absender und kann `text/html`
|
||||
// o.ä. sein. Für inline-Preview nur eine Whitelist "harmloser" Typen
|
||||
// zulassen, sonst zwingend als Download (attachment) ausliefern, um XSS
|
||||
// via inline-HTML-Anhang zu verhindern. Zusätzlich nosniff setzen.
|
||||
const INLINE_SAFE_TYPES = new Set([
|
||||
'application/pdf',
|
||||
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
|
||||
'image/svg+xml' /* wird unten trotzdem als download erzwungen */,
|
||||
'text/plain',
|
||||
]);
|
||||
const rawType = (attachment.contentType || 'application/octet-stream').toLowerCase();
|
||||
// SVG kann Skripte enthalten → niemals inline
|
||||
const isSafeInline = INLINE_SAFE_TYPES.has(rawType) && rawType !== 'image/svg+xml';
|
||||
const requestedDisposition = req.query.view === 'true' ? 'inline' : 'attachment';
|
||||
const disposition = requestedDisposition === 'inline' && isSafeInline ? 'inline' : 'attachment';
|
||||
// Filename: Steuerzeichen entfernen (CRLF-Injection in Header)
|
||||
const safeFilename = (attachment.filename || 'attachment').replace(/[\r\n"\\]/g, '_');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Type', isSafeInline ? rawType : 'application/octet-stream');
|
||||
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(safeFilename)}"`);
|
||||
res.setHeader('Content-Length', attachment.size);
|
||||
res.send(attachment.content);
|
||||
} catch (error) {
|
||||
@@ -1967,6 +2002,10 @@ export async function saveAttachmentAsContractDocument(req: Request, res: Respon
|
||||
},
|
||||
});
|
||||
|
||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||
const deliveryDate = typeof req.body?.deliveryDate === 'string' ? req.body.deliveryDate : null;
|
||||
await maybeActivateOnDeliveryConfirmation(contract.id, documentType, req, deliveryDate);
|
||||
|
||||
res.json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (error) {
|
||||
console.error('saveAttachmentAsContractDocument error:', error);
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as authorizationService from '../services/authorization.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { canAccessContract } from '../utils/accessControl.js';
|
||||
import { maybeActivateOnDeliveryConfirmation } from '../services/contractStatusScheduler.service.js';
|
||||
|
||||
export async function getContracts(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
@@ -461,7 +462,7 @@ export async function getContractDocuments(req: AuthRequest, res: Response): Pro
|
||||
export async function uploadContractDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const contractId = parseInt(req.params.id);
|
||||
const { documentType, notes } = req.body;
|
||||
const { documentType, notes, deliveryDate } = req.body;
|
||||
|
||||
if (!req.file) {
|
||||
res.status(400).json({ success: false, error: 'Keine Datei hochgeladen' } as ApiResponse);
|
||||
@@ -494,6 +495,9 @@ export async function uploadContractDocument(req: AuthRequest, res: Response): P
|
||||
customerId: contract?.customerId,
|
||||
});
|
||||
|
||||
// Falls Lieferbestätigung: DRAFT → ACTIVE + startDate setzen falls leer
|
||||
await maybeActivateOnDeliveryConfirmation(contractId, documentType, req, deliveryDate);
|
||||
|
||||
res.status(201).json({ success: true, data: doc } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
canAccessAddress,
|
||||
canAccessBankCard,
|
||||
canAccessIdentityDocument,
|
||||
canAccessCustomer,
|
||||
} from '../utils/accessControl.js';
|
||||
|
||||
// Customer CRUD
|
||||
@@ -44,7 +45,9 @@ export async function getCustomers(req: AuthRequest, res: Response): Promise<voi
|
||||
|
||||
export async function getCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customer = await customerService.getCustomerById(parseInt(req.params.id));
|
||||
const customerId = parseInt(req.params.id);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const customer = await customerService.getCustomerById(customerId);
|
||||
if (!customer) {
|
||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
@@ -185,18 +188,21 @@ export async function deleteCustomer(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// Addresses
|
||||
export async function getAddresses(req: Request, res: Response): Promise<void> {
|
||||
export async function getAddresses(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const addresses = await customerService.getCustomerAddresses(parseInt(req.params.customerId));
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const addresses = await customerService.getCustomerAddresses(customerId);
|
||||
res.json({ success: true, data: addresses } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Adressen' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAddress(req: Request, res: Response): Promise<void> {
|
||||
export async function createAddress(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const address = await customerService.createAddress(customerId, req.body);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'Address',
|
||||
@@ -298,22 +304,22 @@ export async function deleteAddress(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// Bank Cards
|
||||
export async function getBankCards(req: Request, res: Response): Promise<void> {
|
||||
export async function getBankCards(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const showInactive = req.query.showInactive === 'true';
|
||||
const cards = await customerService.getCustomerBankCards(
|
||||
parseInt(req.params.customerId),
|
||||
showInactive
|
||||
);
|
||||
const cards = await customerService.getCustomerBankCards(customerId, showInactive);
|
||||
res.json({ success: true, data: cards } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Bankkarten' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBankCard(req: Request, res: Response): Promise<void> {
|
||||
export async function createBankCard(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const card = await customerService.createBankCard(customerId, req.body);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'BankCard',
|
||||
@@ -410,22 +416,22 @@ export async function deleteBankCard(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// Identity Documents
|
||||
export async function getDocuments(req: Request, res: Response): Promise<void> {
|
||||
export async function getDocuments(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const showInactive = req.query.showInactive === 'true';
|
||||
const docs = await customerService.getCustomerDocuments(
|
||||
parseInt(req.params.customerId),
|
||||
showInactive
|
||||
);
|
||||
const docs = await customerService.getCustomerDocuments(customerId, showInactive);
|
||||
res.json({ success: true, data: docs } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Ausweise' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDocument(req: Request, res: Response): Promise<void> {
|
||||
export async function createDocument(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const doc = await customerService.createDocument(customerId, req.body);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'IdentityDocument',
|
||||
@@ -528,22 +534,22 @@ export async function deleteDocument(req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
// Meters
|
||||
export async function getMeters(req: Request, res: Response): Promise<void> {
|
||||
export async function getMeters(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const showInactive = req.query.showInactive === 'true';
|
||||
const meters = await customerService.getCustomerMeters(
|
||||
parseInt(req.params.customerId),
|
||||
showInactive
|
||||
);
|
||||
const meters = await customerService.getCustomerMeters(customerId, showInactive);
|
||||
res.json({ success: true, data: meters } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: 'Fehler beim Laden der Zähler' } as ApiResponse);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMeter(req: Request, res: Response): Promise<void> {
|
||||
export async function createMeter(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const meter = await customerService.createMeter(customerId, req.body);
|
||||
await logChange({
|
||||
req, action: 'CREATE', resourceType: 'Meter',
|
||||
@@ -847,9 +853,11 @@ export async function markReadingTransferred(req: AuthRequest, res: Response): P
|
||||
|
||||
// ==================== PORTAL SETTINGS ====================
|
||||
|
||||
export async function getPortalSettings(req: Request, res: Response): Promise<void> {
|
||||
export async function getPortalSettings(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const settings = await customerService.getPortalSettings(parseInt(req.params.customerId));
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const settings = await customerService.getPortalSettings(customerId);
|
||||
if (!settings) {
|
||||
res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse);
|
||||
return;
|
||||
@@ -977,10 +985,12 @@ export async function getPortalPassword(req: Request, res: Response): Promise<vo
|
||||
|
||||
// ==================== REPRESENTATIVE MANAGEMENT ====================
|
||||
|
||||
export async function getRepresentatives(req: Request, res: Response): Promise<void> {
|
||||
export async function getRepresentatives(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
// Wer kann diesen Kunden vertreten (representedBy)?
|
||||
const representedBy = await customerService.getRepresentedByList(parseInt(req.params.customerId));
|
||||
const representedBy = await customerService.getRepresentedByList(customerId);
|
||||
res.json({ success: true, data: representedBy } as ApiResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as gdprService from '../services/gdpr.service.js';
|
||||
import * as consentService from '../services/consent.service.js';
|
||||
import * as consentPublicService from '../services/consent-public.service.js';
|
||||
import * as appSettingService from '../services/appSetting.service.js';
|
||||
import { canAccessCustomer } from '../utils/accessControl.js';
|
||||
import { createAuditLog, logChange } from '../services/audit.service.js';
|
||||
import { ConsentType, DeletionRequestStatus } from '@prisma/client';
|
||||
import prisma from '../lib/prisma.js';
|
||||
@@ -229,6 +230,7 @@ export async function getDashboardStats(req: AuthRequest, res: Response) {
|
||||
export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const consents = await consentService.getCustomerConsents(customerId);
|
||||
|
||||
// Labels hinzufügen
|
||||
@@ -251,6 +253,7 @@ export async function getCustomerConsents(req: AuthRequest, res: Response) {
|
||||
export async function checkConsentStatus(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
const result = await consentService.hasFullConsent(customerId);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
@@ -799,6 +802,7 @@ export async function sendAuthorizationRequest(req: AuthRequest, res: Response)
|
||||
export async function getAuthorizations(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const customerId = parseInt(req.params.customerId);
|
||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||
// Sicherstellen dass Einträge für alle aktiven Vertreter existieren
|
||||
await authorizationService.ensureAuthorizationEntries(customerId);
|
||||
const authorizations = await authorizationService.getAuthorizationsForCustomer(customerId);
|
||||
|
||||
@@ -2,14 +2,15 @@ import { Request, Response } from 'express';
|
||||
import * as invoiceService from '../services/invoice.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { ApiResponse, AuthRequest } from '../types/index.js';
|
||||
import { canAccessContract } from '../utils/accessControl.js';
|
||||
import { canAccessContract, canAccessEnergyContractDetails } from '../utils/accessControl.js';
|
||||
|
||||
/**
|
||||
* Alle Rechnungen für ein EnergyContractDetails abrufen
|
||||
*/
|
||||
export async function getInvoices(req: Request, res: Response): Promise<void> {
|
||||
export async function getInvoices(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const ecdId = parseInt(req.params.ecdId);
|
||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
||||
const invoices = await invoiceService.getInvoices(ecdId);
|
||||
res.json({ success: true, data: invoices } as ApiResponse);
|
||||
} catch (error) {
|
||||
@@ -24,10 +25,11 @@ export async function getInvoices(req: Request, res: Response): Promise<void> {
|
||||
/**
|
||||
* Einzelne Rechnung abrufen
|
||||
*/
|
||||
export async function getInvoice(req: Request, res: Response): Promise<void> {
|
||||
export async function getInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const ecdId = parseInt(req.params.ecdId);
|
||||
const invoiceId = parseInt(req.params.invoiceId);
|
||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
||||
const invoice = await invoiceService.getInvoice(ecdId, invoiceId);
|
||||
|
||||
if (!invoice) {
|
||||
@@ -51,9 +53,10 @@ export async function getInvoice(req: Request, res: Response): Promise<void> {
|
||||
/**
|
||||
* Neue Rechnung hinzufügen
|
||||
*/
|
||||
export async function addInvoice(req: Request, res: Response): Promise<void> {
|
||||
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const ecdId = parseInt(req.params.ecdId);
|
||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
||||
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
||||
|
||||
if (!invoiceDate || !invoiceType) {
|
||||
@@ -90,10 +93,11 @@ export async function addInvoice(req: Request, res: Response): Promise<void> {
|
||||
/**
|
||||
* Rechnung aktualisieren
|
||||
*/
|
||||
export async function updateInvoice(req: Request, res: Response): Promise<void> {
|
||||
export async function updateInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const ecdId = parseInt(req.params.ecdId);
|
||||
const invoiceId = parseInt(req.params.invoiceId);
|
||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
||||
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
|
||||
|
||||
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
|
||||
@@ -122,10 +126,11 @@ export async function updateInvoice(req: Request, res: Response): Promise<void>
|
||||
/**
|
||||
* Rechnung löschen
|
||||
*/
|
||||
export async function deleteInvoice(req: Request, res: Response): Promise<void> {
|
||||
export async function deleteInvoice(req: AuthRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const ecdId = parseInt(req.params.ecdId);
|
||||
const invoiceId = parseInt(req.params.invoiceId);
|
||||
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
|
||||
|
||||
await invoiceService.deleteInvoice(ecdId, invoiceId);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Response } from 'express';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import * as pdfTemplateService from '../services/pdfTemplate.service.js';
|
||||
import { logChange } from '../services/audit.service.js';
|
||||
import { canAccessContract } from '../utils/accessControl.js';
|
||||
|
||||
export async function getTemplates(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
@@ -149,6 +150,7 @@ export async function getRequiredInputs(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const templateId = parseInt(req.params.id);
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
const inputs = await pdfTemplateService.getRequiredInputs(templateId, contractId);
|
||||
res.json({ success: true, data: inputs });
|
||||
} catch (error) {
|
||||
@@ -160,6 +162,7 @@ export async function generatePdf(req: AuthRequest, res: Response) {
|
||||
try {
|
||||
const templateId = parseInt(req.params.id);
|
||||
const contractId = parseInt(req.params.contractId);
|
||||
if (!(await canAccessContract(req, res, contractId))) return;
|
||||
|
||||
// Extras aus Body (POST) oder Query-Parametern (GET)
|
||||
const stressfreiEmailId = req.body?.stressfreiEmailId || req.query.stressfreiEmailId;
|
||||
|
||||
+19
-2
@@ -35,6 +35,7 @@ import pdfTemplateRoutes from './routes/pdfTemplate.routes.js';
|
||||
import birthdayRoutes from './routes/birthday.routes.js';
|
||||
import factoryDefaultsRoutes from './routes/factoryDefaults.routes.js';
|
||||
import { startBirthdayScheduler } from './services/birthdayScheduler.service.js';
|
||||
import { startContractStatusScheduler } from './services/contractStatusScheduler.service.js';
|
||||
import { auditContextMiddleware } from './middleware/auditContext.js';
|
||||
import { auditMiddleware } from './middleware/audit.js';
|
||||
|
||||
@@ -55,6 +56,12 @@ if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) {
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Hinter einem Reverse-Proxy (Nginx/Plesk) läuft der Server typisch auf localhost.
|
||||
// `trust proxy = 1` = dem ersten Hop X-Forwarded-For vertrauen (damit req.ip
|
||||
// die echte Client-IP ist). Wichtig für express-rate-limit, sonst teilen sich
|
||||
// alle Requests dieselbe Proxy-IP und das Rate-Limit ist unwirksam.
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// ==================== SECURITY MIDDLEWARE ====================
|
||||
|
||||
// HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, etc.)
|
||||
@@ -148,13 +155,23 @@ if (process.env.NODE_ENV === 'production') {
|
||||
}
|
||||
|
||||
// Error handling
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
// body-parser wirft 413 (PayloadTooLargeError) bzw. 400 (SyntaxError) mit einem
|
||||
// `status`-Feld. Ohne Respektierung werden legitime Client-Fehler als 500
|
||||
// kaschiert und landen als "Interner Serverfehler" beim User.
|
||||
app.use((err: Error & { status?: number; type?: string }, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({ success: false, error: 'Interner Serverfehler' });
|
||||
const status = typeof err.status === 'number' && err.status >= 400 && err.status < 600 ? err.status : 500;
|
||||
let message = 'Interner Serverfehler';
|
||||
if (status === 413) message = 'Anfrage zu groß';
|
||||
else if (status === 400 && (err.type === 'entity.parse.failed' || err instanceof SyntaxError)) {
|
||||
message = 'Ungültiges JSON';
|
||||
}
|
||||
res.status(status).json({ success: false, error: message });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server läuft auf Port ${PORT}`);
|
||||
// Hintergrund-Scheduler (Geburtstagsgrüße etc.) starten
|
||||
startBirthdayScheduler();
|
||||
startContractStatusScheduler();
|
||||
});
|
||||
|
||||
@@ -27,7 +27,10 @@ export async function authenticate(
|
||||
|
||||
try {
|
||||
// JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts)
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as JwtPayload;
|
||||
// Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion).
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET as string, {
|
||||
algorithms: ['HS256'],
|
||||
}) as JwtPayload;
|
||||
|
||||
// Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde
|
||||
if (decoded.userId && decoded.iat) {
|
||||
|
||||
@@ -5,15 +5,15 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Provider routes
|
||||
router.get('/', authenticate, providerController.getProviders);
|
||||
// Provider routes (Portal-Kunden sollen keine Provider-Liste/Tarife sehen)
|
||||
router.get('/', authenticate, requirePermission('providers:read'), providerController.getProviders);
|
||||
router.post('/', authenticate, requirePermission('providers:create'), providerController.createProvider);
|
||||
router.get('/:id', authenticate, providerController.getProvider);
|
||||
router.get('/:id', authenticate, requirePermission('providers:read'), providerController.getProvider);
|
||||
router.put('/:id', authenticate, requirePermission('providers:update'), providerController.updateProvider);
|
||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), providerController.deleteProvider);
|
||||
|
||||
// Nested tariff routes
|
||||
router.get('/:providerId/tariffs', authenticate, tariffController.getTariffs);
|
||||
router.get('/:providerId/tariffs', authenticate, requirePermission('providers:read'), tariffController.getTariffs);
|
||||
router.post('/:providerId/tariffs', authenticate, requirePermission('providers:create'), tariffController.createTariff);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { authenticate, requirePermission } from '../middleware/auth.js';
|
||||
const router = Router();
|
||||
|
||||
// Standalone tariff routes (for update/delete by tariff id)
|
||||
router.get('/:id', authenticate, tariffController.getTariff);
|
||||
router.get('/:id', authenticate, requirePermission('providers:read'), tariffController.getTariff);
|
||||
router.put('/:id', authenticate, requirePermission('providers:update'), tariffController.updateTariff);
|
||||
router.delete('/:id', authenticate, requirePermission('providers:delete'), tariffController.deleteTariff);
|
||||
|
||||
|
||||
@@ -563,12 +563,51 @@ async function handleContractDocumentUpload(
|
||||
}
|
||||
}
|
||||
|
||||
// Bei Kündigungsbestätigung(s-Optionen): optionales Datum aus multipart
|
||||
// übernehmen. Ohne Angabe: falls Feld noch leer → heute, sonst nicht anfassen.
|
||||
const updateData: Record<string, unknown> = { [fieldName]: relativePath };
|
||||
if (fieldName === 'cancellationConfirmationPath' || fieldName === 'cancellationConfirmationOptionsPath') {
|
||||
const dateField = fieldName === 'cancellationConfirmationPath'
|
||||
? 'cancellationConfirmationDate'
|
||||
: 'cancellationConfirmationOptionsDate';
|
||||
const provided = typeof req.body?.confirmationDate === 'string' ? req.body.confirmationDate : null;
|
||||
let target: Date | null = null;
|
||||
if (provided) {
|
||||
const parsed = new Date(provided);
|
||||
if (!isNaN(parsed.getTime())) target = parsed;
|
||||
}
|
||||
if (target) {
|
||||
updateData[dateField] = target;
|
||||
} else if (!contract[dateField]) {
|
||||
updateData[dateField] = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Vertrag in der DB aktualisieren
|
||||
await prisma.contract.update({
|
||||
where: { id: contractId },
|
||||
data: { [fieldName]: relativePath },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Wenn eine Kündigungsbestätigung (nicht "Optionen") hochgeladen wurde und
|
||||
// der Vertrag noch ACTIVE ist → auf CANCELLED umstellen + Audit-Log.
|
||||
// "Optionen" ist für Vertrags-Änderungen gedacht, nicht für echte Kündigungen.
|
||||
if (fieldName === 'cancellationConfirmationPath' && contract.status === 'ACTIVE') {
|
||||
await prisma.contract.update({
|
||||
where: { id: contractId },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'Contract',
|
||||
resourceId: contractId.toString(),
|
||||
label: `Vertrag ${contract.contractNumber} automatisch auf CANCELLED gesetzt (Kündigungsbestätigung hochgeladen)`,
|
||||
details: { vorher: 'ACTIVE', nachher: 'CANCELLED', trigger: 'cancellationConfirmation-Upload' },
|
||||
customerId: contract.customerId,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
|
||||
@@ -7,6 +7,10 @@ import { encrypt, decrypt } from '../utils/encryption.js';
|
||||
import { sendEmail, SmtpCredentials } from './smtpService.js';
|
||||
import { getSystemEmailCredentials } from './emailProvider/emailProviderService.js';
|
||||
|
||||
// Bcrypt-Cost-Faktor: 12 = OWASP-empfohlen (Stand 2026), ca. 250 ms pro Hash.
|
||||
// Bestehende Hashes mit Faktor 10 bleiben gültig (bcrypt kodiert den Faktor im Hash).
|
||||
const BCRYPT_COST = 12;
|
||||
|
||||
// Mitarbeiter-Login
|
||||
export async function login(email: string, password: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -168,7 +172,7 @@ export async function customerLogin(email: string, password: string) {
|
||||
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 hashedPassword = await bcrypt.hash(password, BCRYPT_COST);
|
||||
const encryptedPassword = encrypt(password);
|
||||
|
||||
console.log('[SetPortalPassword] Hash erstellt, Länge:', hashedPassword.length);
|
||||
@@ -211,7 +215,7 @@ export async function createUser(data: {
|
||||
roleIds: number[];
|
||||
customerId?: number;
|
||||
}) {
|
||||
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||
const hashedPassword = await bcrypt.hash(data.password, BCRYPT_COST);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
@@ -471,7 +475,7 @@ export async function confirmPasswordReset(token: string, newPassword: string):
|
||||
throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(newPassword, 10);
|
||||
const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
@@ -493,7 +497,7 @@ export async function confirmPasswordReset(token: string, newPassword: string):
|
||||
throw new Error('Der Link ist abgelaufen. Bitte fordere einen neuen an.');
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(newPassword, 10);
|
||||
const hash = await bcrypt.hash(newPassword, BCRYPT_COST);
|
||||
await prisma.customer.update({
|
||||
where: { id: customer.id },
|
||||
data: {
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Scheduler für automatische Vertrags-Status-Übergänge.
|
||||
*
|
||||
* Einmal täglich um 02:00: alle Verträge mit status=ACTIVE und
|
||||
* endDate < heute werden auf EXPIRED umgestellt (+ Audit-Log).
|
||||
*
|
||||
* Läuft zusätzlich 60 Sekunden nach Server-Start als Catch-up falls
|
||||
* der Prozess zum 02:00-Slot neu gestartet wurde.
|
||||
*/
|
||||
import cron from 'node-cron';
|
||||
import prisma from '../lib/prisma.js';
|
||||
import { createAuditLog, logChange } from './audit.service.js';
|
||||
|
||||
async function runExpireCheck(): Promise<void> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const expiring = await prisma.contract.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
endDate: { not: null, lt: today },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
contractNumber: true,
|
||||
customerId: true,
|
||||
endDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (expiring.length === 0) {
|
||||
console.log('[ContractStatusScheduler] Keine abgelaufenen Verträge.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[ContractStatusScheduler] ${expiring.length} Vertrag/Verträge auf EXPIRED setzen.`);
|
||||
|
||||
for (const c of expiring) {
|
||||
try {
|
||||
await prisma.contract.update({
|
||||
where: { id: c.id },
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
userEmail: 'system',
|
||||
userRole: 'System',
|
||||
action: 'UPDATE',
|
||||
resourceType: 'Contract',
|
||||
resourceId: c.id.toString(),
|
||||
resourceLabel: `Vertrag ${c.contractNumber} automatisch auf EXPIRED gesetzt (Laufzeit überschritten)`,
|
||||
endpoint: 'scheduler:contract-status',
|
||||
httpMethod: 'SYSTEM',
|
||||
ipAddress: 'localhost',
|
||||
dataSubjectId: c.customerId,
|
||||
changesBefore: { status: 'ACTIVE' },
|
||||
changesAfter: { status: 'EXPIRED', endDate: c.endDate?.toISOString() },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[ContractStatusScheduler] Fehler bei Vertrag #${c.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[ContractStatusScheduler] Fertig.');
|
||||
}
|
||||
|
||||
export function startContractStatusScheduler(): void {
|
||||
// Täglich um 02:00 Uhr (Server-Zeit)
|
||||
cron.schedule('0 2 * * *', () => {
|
||||
runExpireCheck().catch((err) =>
|
||||
console.error('[ContractStatusScheduler] Daily run failed:', err),
|
||||
);
|
||||
});
|
||||
|
||||
// Catch-up 60 Sekunden nach Start
|
||||
setTimeout(() => {
|
||||
runExpireCheck().catch((err) =>
|
||||
console.error('[ContractStatusScheduler] Catch-up run failed:', err),
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
console.log('[ContractStatusScheduler] Gestartet – täglich um 02:00 + Catch-up nach 60s');
|
||||
}
|
||||
|
||||
export { runExpireCheck };
|
||||
|
||||
/**
|
||||
* Wird nach einem ContractDocument-Upload aufgerufen. Wenn der Typ eine
|
||||
* Lieferbestätigung ist:
|
||||
* - Contract.status von DRAFT auf ACTIVE setzen (falls DRAFT)
|
||||
* - Contract.startDate auf deliveryDate (oder heute) setzen, falls noch leer
|
||||
*
|
||||
* Schreibweise "Lieferbestätigung" stammt aus dem Frontend-Dropdown
|
||||
* (SaveAttachmentModal / ContractDetail). Vergleich case-insensitive +
|
||||
* getrimmt zur Robustheit.
|
||||
*/
|
||||
export async function maybeActivateOnDeliveryConfirmation(
|
||||
contractId: number,
|
||||
documentType: string,
|
||||
req: unknown,
|
||||
deliveryDate?: Date | string | null,
|
||||
): Promise<void> {
|
||||
if (!documentType || typeof documentType !== 'string') return;
|
||||
if (documentType.trim().toLowerCase() !== 'lieferbestätigung') return;
|
||||
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: contractId },
|
||||
select: { status: true, contractNumber: true, customerId: true, startDate: true },
|
||||
});
|
||||
if (!contract) return;
|
||||
|
||||
// deliveryDate parsen, Fallback auf heute
|
||||
let parsedDate: Date | null = null;
|
||||
if (deliveryDate) {
|
||||
const parsed = new Date(deliveryDate);
|
||||
if (!isNaN(parsed.getTime())) parsedDate = parsed;
|
||||
}
|
||||
const effectiveDate = parsedDate || new Date();
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
const changes: Record<string, { vorher: unknown; nachher: unknown }> = {};
|
||||
|
||||
if (contract.status === 'DRAFT') {
|
||||
updateData.status = 'ACTIVE';
|
||||
changes.status = { vorher: 'DRAFT', nachher: 'ACTIVE' };
|
||||
}
|
||||
|
||||
if (!contract.startDate) {
|
||||
updateData.startDate = effectiveDate;
|
||||
changes.startDate = { vorher: null, nachher: effectiveDate.toISOString().split('T')[0] };
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) return;
|
||||
|
||||
await prisma.contract.update({
|
||||
where: { id: contractId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await logChange({
|
||||
req,
|
||||
action: 'UPDATE',
|
||||
resourceType: 'Contract',
|
||||
resourceId: contractId.toString(),
|
||||
label: `Vertrag ${contract.contractNumber} automatisch aktualisiert (Lieferbestätigung hochgeladen)`,
|
||||
details: { ...changes, trigger: 'Lieferbestätigung-Upload' },
|
||||
customerId: contract.customerId,
|
||||
});
|
||||
}
|
||||
@@ -49,6 +49,16 @@ export interface EmailLogContext {
|
||||
triggeredBy?: string; // User-Email
|
||||
}
|
||||
|
||||
// Security: zentrale CRLF-Prüfung gegen SMTP-Header-Injection.
|
||||
// Alle Felder, die als Header ausgehen (to/cc/subject/replyTo/references/from),
|
||||
// werden hier geprüft – egal ob der Caller aus cachedEmail, birthday, gdpr,
|
||||
// consent-public oder auth kommt.
|
||||
function containsCRLF(value: unknown): boolean {
|
||||
if (typeof value === 'string') return /[\r\n]/.test(value);
|
||||
if (Array.isArray(value)) return value.some(containsCRLF);
|
||||
return false;
|
||||
}
|
||||
|
||||
// E-Mail senden
|
||||
export async function sendEmail(
|
||||
credentials: SmtpCredentials,
|
||||
@@ -56,6 +66,21 @@ export async function sendEmail(
|
||||
params: SendEmailParams,
|
||||
logContext?: EmailLogContext
|
||||
): Promise<SendEmailResult> {
|
||||
// Header-Injection-Guard (defensiv: Absender, Empfänger, Subject)
|
||||
if (
|
||||
containsCRLF(fromAddress) ||
|
||||
containsCRLF(params.to) ||
|
||||
containsCRLF(params.cc) ||
|
||||
containsCRLF(params.subject) ||
|
||||
containsCRLF(params.inReplyTo) ||
|
||||
containsCRLF(params.references)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Ungültige Zeichen in E-Mail-Header-Feldern (CRLF nicht erlaubt)',
|
||||
};
|
||||
}
|
||||
|
||||
// Verschlüsselungs-Einstellungen basierend auf Modus
|
||||
const encryption = credentials.encryption ?? 'SSL';
|
||||
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
||||
|
||||
@@ -224,3 +224,24 @@ export async function canAccessCachedEmail(
|
||||
'E-Mail',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zugriff auf ein EnergyContractDetails prüfen (ECD → Contract → customerId).
|
||||
*/
|
||||
export async function canAccessEnergyContractDetails(
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
ecdId: number,
|
||||
): Promise<boolean> {
|
||||
if (!req.user?.isCustomerPortal) return true;
|
||||
const ecd = await prisma.energyContractDetails.findUnique({
|
||||
where: { id: ecdId },
|
||||
select: { contract: { select: { customerId: true } } },
|
||||
});
|
||||
return canAccessResourceByCustomerId(
|
||||
req,
|
||||
res,
|
||||
ecd?.contract?.customerId,
|
||||
'Energievertrag',
|
||||
);
|
||||
}
|
||||
|
||||
+52
-1
@@ -97,7 +97,26 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
## ✅ Erledigt
|
||||
|
||||
- [x] **🛡️ Security-Review + Hardening vor Production-Deployment (2 Runden)**
|
||||
- [x] **🔄 Automatische Vertrags-Status-Übergänge**
|
||||
- Nightly-Cron (02:00 + Catch-up 60s nach Start): alle Verträge mit
|
||||
`status=ACTIVE` und `endDate < heute` → `EXPIRED` (mit Audit-Log).
|
||||
- Beim Upload der Kündigungsbestätigung (`cancellationConfirmationPath`):
|
||||
wenn Vertrag aktuell `ACTIVE` → auf `CANCELLED` setzen (Audit-Log).
|
||||
Frontend fragt per Modal das Bestätigungs-Datum ab (Default: heute),
|
||||
wird direkt als `cancellationConfirmationDate` gespeichert.
|
||||
Der "Optionen"-Upload löst den Status-Wechsel bewusst NICHT aus, da er
|
||||
für Vertragsänderungen (nicht echte Kündigungen) gedacht ist, setzt
|
||||
aber `cancellationConfirmationOptionsDate` analog.
|
||||
- Beim Upload einer `Lieferbestätigung` (ContractDocument via direkt-Upload
|
||||
oder Email-Anhang-Import): wenn Vertrag aktuell `DRAFT` → auf `ACTIVE`
|
||||
setzen + `startDate` auf das erfasste Lieferdatum (falls leer).
|
||||
Frontend zeigt Datums-Input conditional, wenn Typ "Lieferbestätigung"
|
||||
ausgewählt ist.
|
||||
- Keine neuen Status eingeführt: `cancellationSentDate` vs.
|
||||
`cancellationConfirmationDate` genügen, um "gesendet vs. bestätigt"
|
||||
abzubilden. `ACTIVE` bleibt bis zur Bestätigung.
|
||||
|
||||
- [x] **🛡️ Security-Review + Hardening vor Production-Deployment (3 Runden)**
|
||||
- Vollständiger Review aller kritischen Bereiche, dokumentiert in **[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)**
|
||||
- **Runde 1 – 6 kritische + 2 wichtige Findings gefixt:**
|
||||
- CORS offen → `CORS_ORIGINS` explizit
|
||||
@@ -113,6 +132,38 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
- Mass Assignment bei Customer/User (Privilege Escalation via `roleIds`!)
|
||||
- 13 weitere IDOR-Stellen (Meter-Readings, Email-Anhänge, StressfreiEmail-Credentials …)
|
||||
- Path-Traversal bei Backup-Name und GDPR-Proof-Download
|
||||
- **Runde 3 – Tiefer Dive (8 weitere Hardenings):**
|
||||
- JWT algorithm confusion: `jwt.verify` auf `algorithms: ['HS256']` festgenagelt
|
||||
- `trust proxy = 1` für Rate-Limiter hinter Reverse-Proxy (sonst unwirksam)
|
||||
- IDOR Invoice (alte `/api/energy-details/:ecdId/invoices`): jetzt `canAccessEnergyContractDetails` → Contract → customerId
|
||||
- IDOR PDF-Template-Generator (`:id/generate/:contractId`): jetzt `canAccessContract`
|
||||
- Email-Anhang-Download: Content-Type-Safelist (HTML/SVG nie inline) + `X-Content-Type-Options: nosniff` + Filename-CRLF-Sanitizing
|
||||
- Provider/Tariff-GETs: `requirePermission('providers:read')` (Portal-Kunden sehen Provider-Liste nicht mehr)
|
||||
- SMTP-Header-Injection: zentrale CRLF-Validierung in `smtpService.sendEmail` (schützt alle Caller)
|
||||
- bcrypt cost 10 → 12 (OWASP 2026)
|
||||
- **Runde 4 – Live-Tests gegen Dev-Server deckten 9 weitere IDORs auf:**
|
||||
- `getCustomer` + `getAddresses`/`getBankCards`/`getDocuments`/`getMeters`/`getRepresentatives`/`getPortalSettings` hatten NUR Daten-Sanitizer aber KEINEN `canAccessCustomer`-Check
|
||||
- `gdpr.getCustomerConsents` + `getAuthorizations` + `checkConsentStatus` ebenso ungeschützt
|
||||
- Portal-Kunde konnte live per `GET /api/customers/<fremde-id>` kompletten Fremdkunden-Datensatz auslesen → jetzt 403
|
||||
- Error-Handler: `err.status` wird jetzt respektiert (413/400 statt pauschalem 500)
|
||||
|
||||
**Live-verifiziert als Portal-Kunde gegen fremden Test-Kunden #4:**
|
||||
|
||||
| Endpoint | Vorher | Nachher |
|
||||
| -------------------------------------------- | ------------------------------- | ---------------------------- |
|
||||
| `GET /api/customers/4` | 🚨 **200 mit Daten** | ✅ 403 |
|
||||
| `GET /api/customers/4/addresses` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/bank-cards` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/documents` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/meters` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/customers/4/representatives` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consents` | 🚨 200 mit Consent-Daten | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/authorizations` | 🚨 200 | ✅ 403 |
|
||||
| `GET /api/gdpr/customer/4/consent-status` | 🚨 200 | ✅ 403 |
|
||||
| Eigene Daten `/api/customers/1` | ✅ 200 | ✅ 200 (unverändert) |
|
||||
| 12 MB Body | 500 „Interner Serverfehler" | ✅ 413 „Anfrage zu groß" |
|
||||
| Malformed JSON | 500 „Interner Serverfehler" | ✅ 400 „Ungültiges JSON" |
|
||||
|
||||
- Deployment-Checkliste komplett
|
||||
|
||||
- [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße**
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function SaveAttachmentModal({
|
||||
const [contractDocumentData, setContractDocumentData] = useState({
|
||||
documentType: CONTRACT_DOCUMENT_TYPES[0],
|
||||
notes: '',
|
||||
deliveryDate: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -130,9 +131,11 @@ export default function SaveAttachmentModal({
|
||||
|
||||
const saveContractDocumentMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const isDelivery = contractDocumentData.documentType.trim().toLowerCase() === 'lieferbestätigung';
|
||||
return cachedEmailApi.saveAttachmentAsContractDocument(emailId, attachmentFilename, {
|
||||
documentType: contractDocumentData.documentType,
|
||||
notes: contractDocumentData.notes || undefined,
|
||||
deliveryDate: isDelivery ? contractDocumentData.deliveryDate : undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -164,6 +167,7 @@ export default function SaveAttachmentModal({
|
||||
setContractDocumentData({
|
||||
documentType: CONTRACT_DOCUMENT_TYPES[0],
|
||||
notes: '',
|
||||
deliveryDate: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
@@ -459,6 +463,23 @@ export default function SaveAttachmentModal({
|
||||
}
|
||||
placeholder="Optionale Anmerkungen..."
|
||||
/>
|
||||
|
||||
{contractDocumentData.documentType.trim().toLowerCase() === 'lieferbestätigung' && (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Lieferdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={contractDocumentData.deliveryDate}
|
||||
onChange={(e) =>
|
||||
setContractDocumentData({ ...contractDocumentData, deliveryDate: e.target.value })
|
||||
}
|
||||
className="block w-full max-w-[220px] px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Falls der Vertrag noch auf Entwurf steht, wird er auf Aktiv gesetzt und dieses Datum als Vertragsbeginn übernommen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1471,6 +1471,12 @@ export default function ContractDetail() {
|
||||
// Un-Snooze Bestätigungsmodal
|
||||
const [showUnsnoozeConfirm, setShowUnsnoozeConfirm] = useState(false);
|
||||
|
||||
// Kündigungsbestätigung-Upload: File gepuffert, Datum-Modal offen
|
||||
const [pendingCancelFile, setPendingCancelFile] = useState<File | null>(null);
|
||||
const [cancelConfirmDate, setCancelConfirmDate] = useState<string>(
|
||||
() => new Date().toISOString().split('T')[0]
|
||||
);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['contract', id],
|
||||
queryFn: () => contractApi.getById(contractId),
|
||||
@@ -2103,8 +2109,13 @@ export default function ContractDetail() {
|
||||
</a>
|
||||
<FileUpload
|
||||
onUpload={async (file) => {
|
||||
await uploadApi.uploadCancellationConfirmation(contractId, file);
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', id] });
|
||||
// Datei puffern, Datums-Modal öffnen
|
||||
setCancelConfirmDate(
|
||||
c.cancellationConfirmationDate
|
||||
? c.cancellationConfirmationDate.split('T')[0]
|
||||
: new Date().toISOString().split('T')[0]
|
||||
);
|
||||
setPendingCancelFile(file);
|
||||
}}
|
||||
existingFile={c.cancellationConfirmationPath}
|
||||
accept=".pdf"
|
||||
@@ -2151,8 +2162,8 @@ export default function ContractDetail() {
|
||||
) : (
|
||||
<FileUpload
|
||||
onUpload={async (file) => {
|
||||
await uploadApi.uploadCancellationConfirmation(contractId, file);
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', id] });
|
||||
setCancelConfirmDate(new Date().toISOString().split('T')[0]);
|
||||
setPendingCancelFile(file);
|
||||
}}
|
||||
accept=".pdf"
|
||||
label="PDF hochladen"
|
||||
@@ -3068,6 +3079,52 @@ export default function ContractDetail() {
|
||||
{/* Status-Info Modal */}
|
||||
<StatusInfoModal isOpen={showStatusInfo} onClose={() => setShowStatusInfo(false)} />
|
||||
|
||||
{/* Kündigungsbestätigung: Datum erfassen und dann Upload */}
|
||||
<Modal
|
||||
isOpen={pendingCancelFile !== null}
|
||||
onClose={() => setPendingCancelFile(null)}
|
||||
title="Kündigungsbestätigung – Datum angeben"
|
||||
size="sm"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-700">
|
||||
Wann wurde die Kündigung vom Anbieter bestätigt? Du kannst das Datum auch später noch anpassen.
|
||||
</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bestätigung erhalten am</label>
|
||||
<input
|
||||
type="date"
|
||||
value={cancelConfirmDate}
|
||||
onChange={(e) => setCancelConfirmDate(e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button variant="secondary" onClick={() => setPendingCancelFile(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!pendingCancelFile) return;
|
||||
try {
|
||||
await uploadApi.uploadCancellationConfirmation(
|
||||
contractId,
|
||||
pendingCancelFile,
|
||||
cancelConfirmDate || undefined,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', id] });
|
||||
setPendingCancelFile(null);
|
||||
} catch (err) {
|
||||
alert('Fehler beim Hochladen: ' + (err instanceof Error ? err.message : 'Unbekannt'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Hochladen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Un-Snooze Bestätigungsmodal */}
|
||||
<Modal
|
||||
isOpen={showUnsnoozeConfirm}
|
||||
@@ -3123,6 +3180,9 @@ function ContractDocumentsSection({
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [uploadType, setUploadType] = useState(DOCUMENT_TYPES[0]);
|
||||
const [uploadNotes, setUploadNotes] = useState('');
|
||||
const [uploadDeliveryDate, setUploadDeliveryDate] = useState<string>(
|
||||
() => new Date().toISOString().split('T')[0],
|
||||
);
|
||||
|
||||
const { data: docsData } = useQuery({
|
||||
queryKey: ['contract-documents', contractId],
|
||||
@@ -3130,10 +3190,12 @@ function ContractDocumentsSection({
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: ({ file, documentType, notes }: { file: File; documentType: string; notes?: string }) =>
|
||||
contractApi.uploadDocument(contractId, file, documentType, notes),
|
||||
mutationFn: ({ file, documentType, notes, deliveryDate }: { file: File; documentType: string; notes?: string; deliveryDate?: string }) =>
|
||||
contractApi.uploadDocument(contractId, file, documentType, notes, deliveryDate),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contract-documents', contractId] });
|
||||
// Contract selbst neu laden – Status kann sich durch Lieferbestätigung geändert haben
|
||||
queryClient.invalidateQueries({ queryKey: ['contract'] });
|
||||
setShowUpload(false);
|
||||
setUploadNotes('');
|
||||
},
|
||||
@@ -3148,10 +3210,17 @@ function ContractDocumentsSection({
|
||||
|
||||
const documents: ContractDocument[] = docsData?.data || [];
|
||||
|
||||
const isDelivery = uploadType.trim().toLowerCase() === 'lieferbestätigung';
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
uploadMutation.mutate({ file, documentType: uploadType, notes: uploadNotes || undefined });
|
||||
uploadMutation.mutate({
|
||||
file,
|
||||
documentType: uploadType,
|
||||
notes: uploadNotes || undefined,
|
||||
deliveryDate: isDelivery ? uploadDeliveryDate : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3197,6 +3266,20 @@ function ContractDocumentsSection({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isDelivery && (
|
||||
<div className="mb-3 p-3 bg-white border border-blue-300 rounded">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Lieferdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={uploadDeliveryDate}
|
||||
onChange={(e) => setUploadDeliveryDate(e.target.value)}
|
||||
className="block w-full max-w-[220px] px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Falls der Vertrag noch auf Entwurf steht, wird er auf Aktiv gesetzt und dieses Datum als Vertragsbeginn übernommen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer text-sm">
|
||||
<Plus className="w-4 h-4" />
|
||||
|
||||
@@ -588,7 +588,7 @@ export const cachedEmailApi = {
|
||||
saveAttachmentAsContractDocument: async (
|
||||
emailId: number,
|
||||
filename: string,
|
||||
params: { documentType: string; notes?: string },
|
||||
params: { documentType: string; notes?: string; deliveryDate?: string },
|
||||
) => {
|
||||
const encodedFilename = encodeURIComponent(filename);
|
||||
const res = await api.post<ApiResponse<{ id: number; documentType: string; documentPath: string }>>(
|
||||
@@ -683,11 +683,12 @@ export const contractApi = {
|
||||
const res = await api.get<ApiResponse<import('../types').ContractDocument[]>>(`/contracts/${contractId}/documents`);
|
||||
return res.data;
|
||||
},
|
||||
uploadDocument: async (contractId: number, file: File, documentType: string, notes?: string) => {
|
||||
uploadDocument: async (contractId: number, file: File, documentType: string, notes?: string, deliveryDate?: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('documentType', documentType);
|
||||
if (notes) formData.append('notes', notes);
|
||||
if (deliveryDate) formData.append('deliveryDate', deliveryDate);
|
||||
const res = await api.post<ApiResponse<import('../types').ContractDocument>>(`/contracts/${contractId}/documents`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
@@ -1087,9 +1088,10 @@ export const uploadApi = {
|
||||
const res = await api.delete<ApiResponse<void>>(`/upload/contracts/${contractId}/cancellation-letter`);
|
||||
return res.data;
|
||||
},
|
||||
uploadCancellationConfirmation: async (contractId: number, file: File) => {
|
||||
uploadCancellationConfirmation: async (contractId: number, file: File, confirmationDate?: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append('document', file);
|
||||
if (confirmationDate) formData.append('confirmationDate', confirmationDate);
|
||||
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/contracts/${contractId}/cancellation-confirmation`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
@@ -1111,9 +1113,10 @@ export const uploadApi = {
|
||||
const res = await api.delete<ApiResponse<void>>(`/upload/contracts/${contractId}/cancellation-letter-options`);
|
||||
return res.data;
|
||||
},
|
||||
uploadCancellationConfirmationOptions: async (contractId: number, file: File) => {
|
||||
uploadCancellationConfirmationOptions: async (contractId: number, file: File, confirmationDate?: string) => {
|
||||
const formData = new FormData();
|
||||
formData.append('document', file);
|
||||
if (confirmationDate) formData.append('confirmationDate', confirmationDate);
|
||||
const res = await api.post<ApiResponse<{ path: string; filename: string }>>(`/upload/contracts/${contractId}/cancellation-confirmation-options`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user