Updates, overview extended, club view implemented
This commit is contained in:
@@ -19,7 +19,7 @@ export const createClubCalendarEvent = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const event = await calendarEventService.createClubEvent(userToken, clubId, req.body);
|
||||
const event = await calendarEventService.createClubEvent(userToken, clubId, req.body, req.user?.id || null);
|
||||
res.status(201).json(event);
|
||||
} catch (error) {
|
||||
console.error('[createClubCalendarEvent] - Error:', error);
|
||||
@@ -28,6 +28,19 @@ export const createClubCalendarEvent = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubCalendarEvent = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, eventId } = req.params;
|
||||
const event = await calendarEventService.updateClubEvent(userToken, clubId, eventId, req.body, req.user?.id || null);
|
||||
res.status(200).json(event);
|
||||
} catch (error) {
|
||||
console.error('[updateClubCalendarEvent] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Speichern des Kalender-Events');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubCalendarEvent = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
|
||||
@@ -4,8 +4,8 @@ class ClubAccountController {
|
||||
async listClubAccounts(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const accounts = await clubAccountService.listClubAccounts(Number(clubId));
|
||||
res.json({ accounts });
|
||||
const payload = await clubAccountService.listClubAccounts(Number(clubId));
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
console.error('[listClubAccounts] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Konten konnten nicht geladen werden.' });
|
||||
@@ -55,6 +55,39 @@ class ClubAccountController {
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async createTransaction(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const transaction = await clubAccountService.createTransaction(Number(clubId), req.user?.id || null, req.body || {});
|
||||
res.status(201).json({ transaction });
|
||||
} catch (error) {
|
||||
console.error('[createClubAccountTransaction] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateTransaction(req, res) {
|
||||
try {
|
||||
const { clubId, transactionId } = req.params;
|
||||
const transaction = await clubAccountService.updateTransaction(Number(clubId), Number(transactionId), req.body || {});
|
||||
res.json({ transaction });
|
||||
} catch (error) {
|
||||
console.error('[updateClubAccountTransaction] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTransaction(req, res) {
|
||||
try {
|
||||
const { clubId, transactionId } = req.params;
|
||||
await clubAccountService.deleteTransaction(Number(clubId), Number(transactionId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubAccountTransaction] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubAccountController();
|
||||
|
||||
132
backend/controllers/clubCommunicationController.js
Normal file
132
backend/controllers/clubCommunicationController.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import clubCommunicationService from '../services/clubCommunicationService.js';
|
||||
|
||||
function handleError(res, scope, error) {
|
||||
console.error(`[${scope}] - Error:`, error);
|
||||
res.status(error?.status || 500).json({
|
||||
error: error?.message || 'internalerror',
|
||||
});
|
||||
}
|
||||
|
||||
const clubCommunicationController = {
|
||||
async list(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = await clubCommunicationService.listClubCommunication(Number(clubId));
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
handleError(res, 'listClubCommunication', error);
|
||||
}
|
||||
},
|
||||
|
||||
async createThread(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const thread = await clubCommunicationService.createThread(Number(clubId), req.user?.id || null, req.body || {});
|
||||
res.status(201).json({ thread });
|
||||
} catch (error) {
|
||||
handleError(res, 'createClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateThread(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
const thread = await clubCommunicationService.updateThread(Number(clubId), Number(threadId), req.body || {});
|
||||
res.json({ thread });
|
||||
} catch (error) {
|
||||
handleError(res, 'updateClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteThread(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
await clubCommunicationService.deleteThread(Number(clubId), Number(threadId));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
handleError(res, 'deleteClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async addMessage(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
const message = await clubCommunicationService.addMessage(Number(clubId), Number(threadId), req.user?.id || null, req.body || {});
|
||||
res.status(201).json({ message });
|
||||
} catch (error) {
|
||||
handleError(res, 'addClubCommunicationMessage', error);
|
||||
}
|
||||
},
|
||||
|
||||
async sendThread(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
const thread = await clubCommunicationService.sendThread(Number(clubId), Number(threadId), req.user?.id || null);
|
||||
res.json({ thread });
|
||||
} catch (error) {
|
||||
handleError(res, 'sendClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async createGroup(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const group = await clubCommunicationService.createGroup(Number(clubId), req.body || {});
|
||||
res.status(201).json({ group });
|
||||
} catch (error) {
|
||||
handleError(res, 'createClubDistributionGroup', error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateGroup(req, res) {
|
||||
try {
|
||||
const { clubId, groupId } = req.params;
|
||||
const group = await clubCommunicationService.updateGroup(Number(clubId), Number(groupId), req.body || {});
|
||||
res.json({ group });
|
||||
} catch (error) {
|
||||
handleError(res, 'updateClubDistributionGroup', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteGroup(req, res) {
|
||||
try {
|
||||
const { clubId, groupId } = req.params;
|
||||
await clubCommunicationService.deleteGroup(Number(clubId), Number(groupId));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
handleError(res, 'deleteClubDistributionGroup', error);
|
||||
}
|
||||
},
|
||||
|
||||
async createTemplate(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const template = await clubCommunicationService.createTemplate(Number(clubId), req.body || {});
|
||||
res.status(201).json({ template });
|
||||
} catch (error) {
|
||||
handleError(res, 'createClubCommunicationTemplate', error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateTemplate(req, res) {
|
||||
try {
|
||||
const { clubId, templateId } = req.params;
|
||||
const template = await clubCommunicationService.updateTemplate(Number(clubId), Number(templateId), req.body || {});
|
||||
res.json({ template });
|
||||
} catch (error) {
|
||||
handleError(res, 'updateClubCommunicationTemplate', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteTemplate(req, res) {
|
||||
try {
|
||||
const { clubId, templateId } = req.params;
|
||||
await clubCommunicationService.deleteTemplate(Number(clubId), Number(templateId));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
handleError(res, 'deleteClubCommunicationTemplate', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default clubCommunicationController;
|
||||
@@ -2,6 +2,8 @@ import { Op } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import {
|
||||
CalendarEvent,
|
||||
ClubCommunicationThread,
|
||||
ClubInvoice,
|
||||
ClubPaymentClaim,
|
||||
ClubRequest,
|
||||
ClubSepaMandate,
|
||||
@@ -10,6 +12,7 @@ import {
|
||||
Member,
|
||||
TrainingGroup,
|
||||
} from '../models/index.js';
|
||||
import clubArchiveService from '../services/clubArchiveService.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorUtils.js';
|
||||
|
||||
function formatRequestWorkflowStage(stage) {
|
||||
@@ -23,6 +26,8 @@ function formatRequestWorkflowStage(stage) {
|
||||
sepa_pending: 'SEPA ausstehend',
|
||||
onboarding_completed: 'Onboarding abgeschlossen',
|
||||
sponsoring_contacted: 'Sponsoring kontaktiert',
|
||||
sponsoring_offer_prepared: 'Sponsoringangebot vorbereitet',
|
||||
sponsoring_followed_up: 'Sponsoring nachgefasst',
|
||||
}[stage] || stage;
|
||||
}
|
||||
|
||||
@@ -37,6 +42,10 @@ function formatTaskType(taskType) {
|
||||
membership_collect_sepa_mandate: 'SEPA organisieren',
|
||||
membership_assign_fee: 'Beitrag zuordnen',
|
||||
request_sponsoring_reply: 'Sponsoring nachfassen',
|
||||
sponsoring_prepare_offer: 'Sponsoringangebot vorbereiten',
|
||||
sponsoring_follow_up: 'Sponsoring nachfassen',
|
||||
document_review_required: 'Dokument prüfen',
|
||||
sponsor_contract_renewal: 'Sponsoring verlängern',
|
||||
member_missing_email: 'E-Mail ergänzen',
|
||||
member_missing_birthdate: 'Geburtsdatum ergänzen',
|
||||
member_missing_sepa_mandate: 'SEPA-Mandat einholen',
|
||||
@@ -48,6 +57,26 @@ function formatTaskType(taskType) {
|
||||
}[taskType] || taskType || 'Freie Aufgabe';
|
||||
}
|
||||
|
||||
function formatCommunicationStatus(status) {
|
||||
return {
|
||||
draft: 'Entwurf',
|
||||
scheduled: 'Geplant',
|
||||
sent: 'Versendet',
|
||||
archived: 'Archiviert',
|
||||
}[status] || status || 'Status';
|
||||
}
|
||||
|
||||
function formatInvoiceStatus(status) {
|
||||
return {
|
||||
draft: 'Entwurf',
|
||||
issued: 'Gestellt',
|
||||
partially_paid: 'Teilbezahlt',
|
||||
paid: 'Bezahlt',
|
||||
cancelled: 'Storniert',
|
||||
archived: 'Archiviert',
|
||||
}[status] || status || 'Status';
|
||||
}
|
||||
|
||||
function formatEventDateRange(event) {
|
||||
if (!event?.startDate) {
|
||||
return null;
|
||||
@@ -230,6 +259,9 @@ export const getClubDashboard = async (req, res) => {
|
||||
upcomingEvents,
|
||||
trainingGroups,
|
||||
upcomingMatches,
|
||||
communicationThreads,
|
||||
invoices,
|
||||
archive,
|
||||
] = await Promise.all([
|
||||
loadOptionalTableData(availableTables, 'club_requests', () => ClubRequest.findAll({
|
||||
where: {
|
||||
@@ -302,6 +334,22 @@ export const getClubDashboard = async (req, res) => {
|
||||
order: [['date', 'ASC'], ['time', 'ASC']],
|
||||
limit: 5,
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'club_communication_threads', () => ClubCommunicationThread.findAll({
|
||||
where: { clubId },
|
||||
attributes: ['id', 'subject', 'status', 'threadType', 'scheduledAt', 'sentAt', 'updatedAt'],
|
||||
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
|
||||
limit: 8,
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'club_invoices', () => ClubInvoice.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
archivedAt: null,
|
||||
},
|
||||
attributes: ['id', 'invoiceNumber', 'invoiceDirection', 'invoiceType', 'status', 'dueOn', 'grossAmountCents', 'currencyCode', 'updatedAt'],
|
||||
order: [['updatedAt', 'DESC'], ['dueOn', 'ASC']],
|
||||
limit: 8,
|
||||
})),
|
||||
clubArchiveService.getClubArchive(clubId),
|
||||
]);
|
||||
|
||||
const visibleDashboardTasks = tasks.filter((task) => !task.assignedUserId || Number(task.assignedUserId) === currentUserId);
|
||||
@@ -335,6 +383,12 @@ export const getClubDashboard = async (req, res) => {
|
||||
(workflowStageCounts.sepa_pending || 0);
|
||||
const duePaymentCount = paymentClaims.filter((claim) => claim.status === 'open').length;
|
||||
const reminderCount = paymentClaims.filter((claim) => Number(claim.reminderLevel || 0) > 0).length;
|
||||
const openInvoices = invoices.filter((invoice) => ['draft', 'issued', 'partially_paid'].includes(invoice.status));
|
||||
const outgoingOpenInvoices = openInvoices.filter((invoice) => invoice.invoiceDirection === 'outgoing');
|
||||
const incomingOpenInvoices = openInvoices.filter((invoice) => invoice.invoiceDirection === 'incoming');
|
||||
const communicationDraftCount = communicationThreads.filter((thread) => thread.status === 'draft').length;
|
||||
const communicationScheduledCount = communicationThreads.filter((thread) => thread.status === 'scheduled').length;
|
||||
const communicationSentCount = communicationThreads.filter((thread) => thread.status === 'sent').length;
|
||||
const recentMembers = members.slice(0, 4);
|
||||
const upcomingTrainings = buildUpcomingTrainingSlots(trainingGroups);
|
||||
const paidRatio = paymentClaims.length === 0
|
||||
@@ -382,6 +436,7 @@ export const getClubDashboard = async (req, res) => {
|
||||
value: `${openRequestCount + inProgressRequestCount}`,
|
||||
meta: trialTrainingCount > 0 ? `${trialTrainingCount} Probetrainings` : null,
|
||||
to: '/club-requests',
|
||||
accent: 'amber',
|
||||
items: [
|
||||
createDashboardItem(`${openRequestCount} offen`, '/club-requests'),
|
||||
createDashboardItem(`${inProgressRequestCount} in Bearbeitung`, '/club-requests'),
|
||||
@@ -392,6 +447,7 @@ export const getClubDashboard = async (req, res) => {
|
||||
value: `${onboardingCount}`,
|
||||
meta: onboardingCount > 0 ? 'im Aufnahme- und Onboardingprozess' : 'Keine aktiven Onboarding-Fälle',
|
||||
to: '/club-requests',
|
||||
accent: 'green',
|
||||
items: [
|
||||
createDashboardItem(`${workflowStageCounts.trial_training_scheduled || 0} Probetrainings terminiert`, '/club-requests'),
|
||||
createDashboardItem(`${workflowStageCounts.membership_reviewed || 0} Mitgliedsanfragen geprüft`, '/club-requests'),
|
||||
@@ -401,13 +457,17 @@ export const getClubDashboard = async (req, res) => {
|
||||
{
|
||||
title: 'Offene Zahlungen',
|
||||
value: `${paymentClaims.length}`,
|
||||
meta: reminderCount > 0 ? `${reminderCount} mit Mahnstufe` : 'Keine Mahnungen aktiv',
|
||||
to: '/club-tasks',
|
||||
meta: [
|
||||
duePaymentCount > 0 ? `${duePaymentCount} fällig offen` : null,
|
||||
reminderCount > 0 ? `${reminderCount} mit Mahnstufe` : null,
|
||||
].filter(Boolean).join(' · ') || 'Keine Mahnungen aktiv',
|
||||
to: '/club-payments',
|
||||
accent: 'red',
|
||||
items: paymentClaims.slice(0, 3).map((claim) => {
|
||||
const amount = `${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'}`;
|
||||
return createDashboardItem(
|
||||
`${amount} fällig am ${claim.dueOn}`,
|
||||
claim.memberId ? buildMemberRoute(claim.memberId, 'active') : '/club-tasks'
|
||||
`${amount} · ${claim.dueOn ? `fällig ${claim.dueOn}` : 'ohne Fälligkeit'}`,
|
||||
claim.memberId ? buildMemberRoute(claim.memberId, 'active') : '/club-payments'
|
||||
);
|
||||
}),
|
||||
},
|
||||
@@ -415,6 +475,7 @@ export const getClubDashboard = async (req, res) => {
|
||||
title: 'Fehlende Daten',
|
||||
value: `${missingFields.email + missingFields.birthDate + missingMandateCount}`,
|
||||
to: '/members',
|
||||
accent: 'blue',
|
||||
items: [
|
||||
createDashboardItem(`${missingFields.email} Mitglieder ohne E-Mail`, { path: '/members', query: { scope: 'dataIncomplete' } }),
|
||||
createDashboardItem(`${missingFields.birthDate} Mitglieder ohne Geburtsdatum`, { path: '/members', query: { scope: 'dataIncomplete' } }),
|
||||
@@ -426,6 +487,7 @@ export const getClubDashboard = async (req, res) => {
|
||||
value: `${openTasks.length + inProgressTasks.length}`,
|
||||
meta: overdueTaskCount > 0 ? `${overdueTaskCount} überfällig` : 'Keine überfälligen Aufgaben',
|
||||
to: '/club-tasks',
|
||||
accent: overdueTaskCount > 0 ? 'red' : 'green',
|
||||
items: [
|
||||
createDashboardItem(`${automatedOpenTasks.length} automatisch erzeugte Schritte`, '/club-tasks'),
|
||||
...visibleDashboardTasks.slice(0, 3).map((task) => taskDashboardItem(task, task.title)),
|
||||
@@ -433,6 +495,46 @@ export const getClubDashboard = async (req, res) => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'operations-room',
|
||||
title: 'Kommunikation, Finanzen und Archiv',
|
||||
cards: [
|
||||
{
|
||||
title: 'Kommunikation',
|
||||
value: `${communicationThreads.length}`,
|
||||
meta: `${communicationDraftCount} Entwürfe, ${communicationScheduledCount} geplant, ${communicationSentCount} versendet`,
|
||||
to: '/club-communication',
|
||||
accent: 'blue',
|
||||
items: communicationThreads.slice(0, 3).map((thread) => createDashboardItem(
|
||||
`${thread.subject || 'Ohne Betreff'} · ${formatCommunicationStatus(thread.status)}`,
|
||||
'/club-communication'
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: 'Rechnungen',
|
||||
value: `${openInvoices.length}`,
|
||||
meta: `${outgoingOpenInvoices.length} ausgehend, ${incomingOpenInvoices.length} eingehend`,
|
||||
to: '/club-invoices',
|
||||
accent: 'amber',
|
||||
items: openInvoices.slice(0, 3).map((invoice) => createDashboardItem(
|
||||
`${invoice.invoiceNumber || `Rechnung #${invoice.id}`} · ${formatInvoiceStatus(invoice.status)}`,
|
||||
'/club-invoices'
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: 'Archiv',
|
||||
value: `${archive.summary.archivedRequests + archive.summary.archivedTasks + archive.summary.archivedClaims}`,
|
||||
meta: `${archive.summary.inactiveMembers} inaktive Mitglieder`,
|
||||
to: '/club-archive',
|
||||
accent: 'neutral',
|
||||
items: [
|
||||
createDashboardItem(`${archive.summary.archivedRequests} archivierte Anfragen`, '/club-archive'),
|
||||
createDashboardItem(`${archive.summary.archivedTasks} archivierte Aufgaben`, '/club-archive'),
|
||||
createDashboardItem(`${archive.summary.archivedClaims} archivierte Beitragsfälle`, '/club-archive'),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'appointments',
|
||||
title: 'Aktuelle Termine',
|
||||
@@ -537,9 +639,17 @@ export const getClubDashboard = async (req, res) => {
|
||||
? 'Anfrage'
|
||||
: task.automationSource === 'club_payment_claims'
|
||||
? 'Zahlung'
|
||||
: task.automationSource === 'calendar_events'
|
||||
? 'Termin'
|
||||
: 'Workflow';
|
||||
: task.automationSource === 'club_documents'
|
||||
? 'Dokument'
|
||||
: task.automationSource === 'club_invoice_parties'
|
||||
? 'Sponsor'
|
||||
: task.automationSource === 'club_invoices'
|
||||
? 'Rechnung'
|
||||
: task.automationSource === 'club_communication'
|
||||
? 'Kommunikation'
|
||||
: task.automationSource === 'calendar_events'
|
||||
? 'Termin'
|
||||
: 'Workflow';
|
||||
return taskDashboardItem(task, `${formatTaskType(task.taskType)} · ${sourceLabel}`);
|
||||
}),
|
||||
},
|
||||
|
||||
111
backend/controllers/clubDocumentController.js
Normal file
111
backend/controllers/clubDocumentController.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import clubDocumentService from '../services/clubDocumentService.js';
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
fs.mkdirSync('uploads/temp', { recursive: true });
|
||||
cb(null, 'uploads/temp/');
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
cb(null, `${file.fieldname}-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 20 * 1024 * 1024,
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedExtensions = ['.pdf', '.doc', '.docx', '.txt', '.csv', '.odt', '.xlsx'];
|
||||
const allowedMimePatterns = ['pdf', 'msword', 'wordprocessingml.document', 'text/plain', 'csv', 'excel', 'opendocument.text'];
|
||||
const extensionValid = allowedExtensions.includes(path.extname(file.originalname).toLowerCase());
|
||||
const mimetypeValid = allowedMimePatterns.some((pattern) => file.mimetype && file.mimetype.toLowerCase().includes(pattern));
|
||||
if (extensionValid && mimetypeValid) {
|
||||
return cb(null, true);
|
||||
}
|
||||
cb(new Error('Nur PDF, DOC, DOCX, TXT, CSV, ODT und XLSX Dateien sind erlaubt!'));
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadMiddleware = upload.single('document');
|
||||
|
||||
function cleanupTempFile(file) {
|
||||
if (file?.path && fs.existsSync(file.path)) {
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
export const listClubDocuments = async (req, res) => {
|
||||
try {
|
||||
const documents = await clubDocumentService.listClubDocuments(Number(req.params.clubId), req.query || {});
|
||||
res.status(200).json({ documents });
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubDocument = async (req, res) => {
|
||||
try {
|
||||
const document = await clubDocumentService.createClubDocument(Number(req.params.clubId), req.user?.id || null, req.body || {}, req.file || null);
|
||||
res.status(201).json({ document });
|
||||
} catch (error) {
|
||||
cleanupTempFile(req.file);
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubDocument = async (req, res) => {
|
||||
try {
|
||||
const document = await clubDocumentService.updateClubDocument(Number(req.params.clubId), Number(req.params.documentId), req.user?.id || null, req.body || {}, req.file || null);
|
||||
res.status(200).json({ document });
|
||||
} catch (error) {
|
||||
cleanupTempFile(req.file);
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const archiveClubDocument = async (req, res) => {
|
||||
try {
|
||||
const document = await clubDocumentService.archiveClubDocument(Number(req.params.clubId), Number(req.params.documentId));
|
||||
res.status(200).json({ document });
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubDocument = async (req, res) => {
|
||||
try {
|
||||
await clubDocumentService.deleteClubDocument(Number(req.params.clubId), Number(req.params.documentId));
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadClubDocument = async (req, res) => {
|
||||
try {
|
||||
const versionNo = req.query.versionNo ? Number(req.query.versionNo) : null;
|
||||
const { document, version } = await clubDocumentService.getDocumentDownload(Number(req.params.clubId), Number(req.params.documentId), versionNo);
|
||||
if (!fs.existsSync(version.storagePath)) {
|
||||
return res.status(404).json({ error: 'filenotfound' });
|
||||
}
|
||||
res.setHeader('Content-Disposition', `inline; filename="${document.title || version.fileName}"`);
|
||||
res.setHeader('Content-Type', version.mimeType || 'application/octet-stream');
|
||||
res.sendFile(version.storagePath);
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
listClubDocuments,
|
||||
createClubDocument,
|
||||
updateClubDocument,
|
||||
archiveClubDocument,
|
||||
deleteClubDocument,
|
||||
downloadClubDocument,
|
||||
};
|
||||
73
backend/controllers/clubPaymentClaimController.js
Normal file
73
backend/controllers/clubPaymentClaimController.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import clubPaymentClaimService from '../services/clubPaymentClaimService.js';
|
||||
|
||||
class ClubPaymentClaimController {
|
||||
async list(req, res) {
|
||||
try {
|
||||
const payload = await clubPaymentClaimService.listClaims(Number(req.params.clubId));
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
console.error('[listClubPaymentClaims] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderungen konnten nicht geladen werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.createClaim(Number(req.params.clubId), req.body || {});
|
||||
res.status(201).json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[createClubPaymentClaim] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.updateClaim(Number(req.params.clubId), Number(req.params.claimId), req.body || {});
|
||||
res.json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[updateClubPaymentClaim] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.updateClaimStatus(
|
||||
Number(req.params.clubId),
|
||||
Number(req.params.claimId),
|
||||
String(req.body?.status || '')
|
||||
);
|
||||
res.json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[updateClubPaymentClaimStatus] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Status konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async registerPayment(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.registerPayment(
|
||||
Number(req.params.clubId),
|
||||
Number(req.params.claimId),
|
||||
req.body || {}
|
||||
);
|
||||
res.json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[registerClubPaymentClaimPayment] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Teilzahlung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req, res) {
|
||||
try {
|
||||
await clubPaymentClaimService.deleteClaim(Number(req.params.clubId), Number(req.params.claimId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubPaymentClaim] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubPaymentClaimController();
|
||||
@@ -119,6 +119,7 @@ export const listClubTasks = async (req, res) => {
|
||||
res.status(200).json({
|
||||
tasks,
|
||||
taskDefinitions: automationOverview.definitions,
|
||||
workflowSources: automationOverview.workflowSources || [],
|
||||
taskSuggestions: automationOverview.suggestions,
|
||||
assignableUsers,
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ export const updateClubSettings = async (req, res) => {
|
||||
countryCode,
|
||||
stateCode,
|
||||
memberDataQualityRequirements,
|
||||
feeRules,
|
||||
outgoingInvoicePrefix,
|
||||
outgoingInvoiceNextNumber,
|
||||
incomingInvoicePrefix,
|
||||
@@ -81,6 +82,7 @@ export const updateClubSettings = async (req, res) => {
|
||||
countryCode,
|
||||
stateCode,
|
||||
memberDataQualityRequirements,
|
||||
feeRules,
|
||||
outgoingInvoicePrefix,
|
||||
outgoingInvoiceNextNumber,
|
||||
incomingInvoicePrefix,
|
||||
|
||||
@@ -29,11 +29,12 @@ const getWaitingApprovals = async(req, res) => {
|
||||
const setClubMembers = async (req, res) => {
|
||||
try {
|
||||
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
|
||||
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts } = req.body;
|
||||
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved,
|
||||
contributionGroupCode, contacts } = req.body;
|
||||
const { id: clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate,
|
||||
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts);
|
||||
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contributionGroupCode, contacts);
|
||||
|
||||
// Emit Socket-Event wenn Member erfolgreich erstellt/aktualisiert wurde
|
||||
if (addResult.status === 200) {
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
club_id INT NOT NULL,
|
||||
event_type VARCHAR(32) NOT NULL DEFAULT 'club_event',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'planning',
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
location VARCHAR(255) NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
registration_deadline DATE NULL,
|
||||
organizer_user_id INT NULL,
|
||||
category VARCHAR(64) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_calendar_events_club_start (club_id, start_date),
|
||||
KEY idx_calendar_events_club_status (club_id, status),
|
||||
KEY idx_calendar_events_club_event_type (club_id, event_type),
|
||||
CONSTRAINT fk_calendar_events_club
|
||||
FOREIGN KEY (club_id) REFERENCES clubs(id)
|
||||
ON DELETE CASCADE
|
||||
|
||||
@@ -10,16 +10,44 @@ const CalendarEvent = sequelize.define('CalendarEvent', {
|
||||
references: { model: Club, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
eventType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'club_event',
|
||||
field: 'event_type',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'planning',
|
||||
},
|
||||
title: { type: DataTypes.STRING(255), allowNull: false },
|
||||
description: { type: DataTypes.TEXT, allowNull: true },
|
||||
location: { type: DataTypes.STRING(255), allowNull: true },
|
||||
startDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'start_date' },
|
||||
endDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'end_date' },
|
||||
registrationDeadline: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'registration_deadline',
|
||||
},
|
||||
organizerUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'organizer_user_id',
|
||||
},
|
||||
category: { type: DataTypes.STRING(64), allowNull: true },
|
||||
notes: { type: DataTypes.TEXT, allowNull: true },
|
||||
}, {
|
||||
tableName: 'calendar_events',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [{ fields: ['club_id', 'start_date'] }],
|
||||
indexes: [
|
||||
{ fields: ['club_id', 'start_date'] },
|
||||
{ fields: ['club_id', 'event_type'] },
|
||||
{ fields: ['club_id', 'status'] },
|
||||
{ fields: ['club_id', 'registration_deadline'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default CalendarEvent;
|
||||
|
||||
@@ -49,6 +49,12 @@ const Club = sequelize.define('Club', {
|
||||
field: 'member_data_quality_requirements',
|
||||
comment: 'Configures which member fields are required for data quality checks'
|
||||
},
|
||||
feeRules: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'fee_rules',
|
||||
comment: 'Manual contribution rules for common club member groups'
|
||||
},
|
||||
outgoingInvoicePrefix: {
|
||||
type: DataTypes.STRING(24),
|
||||
allowNull: false,
|
||||
|
||||
87
backend/models/ClubAccountTransaction.js
Normal file
87
backend/models/ClubAccountTransaction.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubAccountTransaction = sequelize.define('ClubAccountTransaction', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
accountId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'account_id',
|
||||
},
|
||||
invoiceId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'invoice_id',
|
||||
},
|
||||
paymentClaimId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'payment_claim_id',
|
||||
},
|
||||
direction: {
|
||||
type: DataTypes.ENUM('credit', 'debit'),
|
||||
allowNull: false,
|
||||
defaultValue: 'credit',
|
||||
},
|
||||
bookingType: {
|
||||
type: DataTypes.ENUM('manual', 'invoice', 'adjustment', 'payment_claim'),
|
||||
allowNull: false,
|
||||
defaultValue: 'manual',
|
||||
field: 'booking_type',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('planned', 'booked', 'cancelled'),
|
||||
allowNull: false,
|
||||
defaultValue: 'booked',
|
||||
},
|
||||
bookingDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
field: 'booking_date',
|
||||
},
|
||||
valueDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'value_date',
|
||||
},
|
||||
amountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'amount_cents',
|
||||
},
|
||||
currencyCode: {
|
||||
type: DataTypes.STRING(3),
|
||||
allowNull: false,
|
||||
defaultValue: 'EUR',
|
||||
field: 'currency_code',
|
||||
},
|
||||
reference: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_account_transactions',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubAccountTransaction;
|
||||
73
backend/models/ClubCommunicationDeliveryLog.js
Normal file
73
backend/models/ClubCommunicationDeliveryLog.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationDeliveryLog = sequelize.define('ClubCommunicationDeliveryLog', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
threadId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'thread_id',
|
||||
},
|
||||
recipientId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'recipient_id',
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('sent', 'failed', 'skipped'),
|
||||
allowNull: false,
|
||||
defaultValue: 'failed',
|
||||
},
|
||||
attemptNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'attempt_no',
|
||||
},
|
||||
retryable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
errorCode: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'error_code',
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'error_message',
|
||||
},
|
||||
transportMessageId: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'transport_message_id',
|
||||
},
|
||||
transportResponse: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'transport_response',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_delivery_logs',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationDeliveryLog;
|
||||
47
backend/models/ClubCommunicationMessage.js
Normal file
47
backend/models/ClubCommunicationMessage.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationMessage = sequelize.define('ClubCommunicationMessage', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
threadId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'thread_id',
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
messageType: {
|
||||
type: DataTypes.ENUM('message', 'note'),
|
||||
allowNull: false,
|
||||
defaultValue: 'message',
|
||||
field: 'message_type',
|
||||
},
|
||||
direction: {
|
||||
type: DataTypes.ENUM('outbound', 'internal', 'inbound'),
|
||||
allowNull: false,
|
||||
defaultValue: 'outbound',
|
||||
},
|
||||
body: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_messages',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationMessage;
|
||||
84
backend/models/ClubCommunicationRecipient.js
Normal file
84
backend/models/ClubCommunicationRecipient.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationRecipient = sequelize.define('ClubCommunicationRecipient', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
threadId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'thread_id',
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
memberId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'member_id',
|
||||
},
|
||||
recipientName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'recipient_name',
|
||||
},
|
||||
emailSnapshot: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'email_snapshot',
|
||||
},
|
||||
deliveryStatus: {
|
||||
type: DataTypes.ENUM('pending', 'sent', 'failed', 'skipped'),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
field: 'delivery_status',
|
||||
},
|
||||
deliveredAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'delivered_at',
|
||||
},
|
||||
lastAttemptAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_attempt_at',
|
||||
},
|
||||
attemptCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'attempt_count',
|
||||
},
|
||||
retryable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
errorCode: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'error_code',
|
||||
},
|
||||
transportMessageId: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'transport_message_id',
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'error_message',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_recipients',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationRecipient;
|
||||
58
backend/models/ClubCommunicationTemplate.js
Normal file
58
backend/models/ClubCommunicationTemplate.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationTemplate = sequelize.define('ClubCommunicationTemplate', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(160),
|
||||
allowNull: false,
|
||||
},
|
||||
category: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
defaultValue: 'general',
|
||||
},
|
||||
subjectTemplate: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'subject_template',
|
||||
},
|
||||
bodyTemplate: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
field: 'body_template',
|
||||
},
|
||||
variablesHint: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'variables_hint',
|
||||
},
|
||||
isSystemTemplate: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_system_template',
|
||||
},
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'sort_order',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_templates',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationTemplate;
|
||||
67
backend/models/ClubCommunicationThread.js
Normal file
67
backend/models/ClubCommunicationThread.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationThread = sequelize.define('ClubCommunicationThread', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
threadType: {
|
||||
type: DataTypes.ENUM('direct', 'group', 'broadcast'),
|
||||
allowNull: false,
|
||||
defaultValue: 'direct',
|
||||
field: 'thread_type',
|
||||
},
|
||||
subject: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('draft', 'scheduled', 'sent', 'archived'),
|
||||
allowNull: false,
|
||||
defaultValue: 'draft',
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
distributionGroupId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'distribution_group_id',
|
||||
},
|
||||
recipientMemberId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'recipient_member_id',
|
||||
},
|
||||
scheduledAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'scheduled_at',
|
||||
},
|
||||
recipientFilters: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'recipient_filters',
|
||||
},
|
||||
sentAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'sent_at',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_threads',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationThread;
|
||||
47
backend/models/ClubDistributionGroup.js
Normal file
47
backend/models/ClubDistributionGroup.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDistributionGroup = sequelize.define('ClubDistributionGroup', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
groupType: {
|
||||
type: DataTypes.ENUM('manual', 'training_group', 'team', 'custom'),
|
||||
allowNull: false,
|
||||
defaultValue: 'manual',
|
||||
field: 'group_type',
|
||||
},
|
||||
isSystemGroup: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_system_group',
|
||||
},
|
||||
filterDefinition: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'filter_definition',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_distribution_groups',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubDistributionGroup;
|
||||
33
backend/models/ClubDistributionGroupMember.js
Normal file
33
backend/models/ClubDistributionGroupMember.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDistributionGroupMember = sequelize.define('ClubDistributionGroupMember', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
groupId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'group_id',
|
||||
},
|
||||
memberId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'member_id',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_distribution_group_members',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['group_id', 'member_id'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDistributionGroupMember;
|
||||
61
backend/models/ClubDocument.js
Normal file
61
backend/models/ClubDocument.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDocument = sequelize.define('ClubDocument', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
documentType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
field: 'document_type',
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'active',
|
||||
},
|
||||
visibilityScope: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'board',
|
||||
field: 'visibility_scope',
|
||||
},
|
||||
ownerUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'owner_user_id',
|
||||
},
|
||||
currentVersionNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'current_version_no',
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'archived_at',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_documents',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['club_id', 'document_type', 'status'] },
|
||||
{ fields: ['club_id', 'visibility_scope'] },
|
||||
{ fields: ['club_id', 'current_version_no'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDocument;
|
||||
35
backend/models/ClubDocumentLink.js
Normal file
35
backend/models/ClubDocumentLink.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDocumentLink = sequelize.define('ClubDocumentLink', {
|
||||
documentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'document_id',
|
||||
},
|
||||
linkedEntityType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
field: 'linked_entity_type',
|
||||
},
|
||||
linkedEntityId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'linked_entity_id',
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_document_links',
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ fields: ['linked_entity_type', 'linked_entity_id'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDocumentLink;
|
||||
65
backend/models/ClubDocumentVersion.js
Normal file
65
backend/models/ClubDocumentVersion.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDocumentVersion = sequelize.define('ClubDocumentVersion', {
|
||||
documentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'document_id',
|
||||
},
|
||||
versionNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'version_no',
|
||||
},
|
||||
fileName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'file_name',
|
||||
},
|
||||
storagePath: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false,
|
||||
field: 'storage_path',
|
||||
},
|
||||
mimeType: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
field: 'mime_type',
|
||||
},
|
||||
fileSizeBytes: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'file_size_bytes',
|
||||
},
|
||||
checksumSha256: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'checksum_sha256',
|
||||
},
|
||||
uploadedByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'uploaded_by_user_id',
|
||||
},
|
||||
uploadedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'uploaded_at',
|
||||
},
|
||||
changeNote: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'change_note',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_document_versions',
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ unique: true, fields: ['document_id', 'version_no'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDocumentVersion;
|
||||
@@ -13,10 +13,30 @@ const ClubInvoiceParty = sequelize.define('ClubInvoiceParty', {
|
||||
defaultValue: 'customer',
|
||||
field: 'party_type',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'active',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
contractReference: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
field: 'contract_reference',
|
||||
},
|
||||
validFrom: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'valid_from',
|
||||
},
|
||||
validTo: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'valid_to',
|
||||
},
|
||||
contactName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
@@ -70,6 +90,12 @@ const ClubInvoiceParty = sequelize.define('ClubInvoiceParty', {
|
||||
tableName: 'club_invoice_parties',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['club_id', 'party_type'] },
|
||||
{ fields: ['club_id', 'status'] },
|
||||
{ fields: ['club_id', 'valid_from'] },
|
||||
{ fields: ['club_id', 'valid_to'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubInvoiceParty;
|
||||
|
||||
@@ -38,6 +38,12 @@ const ClubPaymentClaim = sequelize.define('ClubPaymentClaim', {
|
||||
allowNull: false,
|
||||
field: 'amount_cents'
|
||||
},
|
||||
paidAmountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'paid_amount_cents'
|
||||
},
|
||||
currencyCode: {
|
||||
type: DataTypes.STRING(3),
|
||||
allowNull: false,
|
||||
@@ -64,6 +70,11 @@ const ClubPaymentClaim = sequelize.define('ClubPaymentClaim', {
|
||||
allowNull: true,
|
||||
field: 'settled_at'
|
||||
},
|
||||
lastPaidAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_paid_at'
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
|
||||
@@ -123,6 +123,60 @@ const Member = sequelize.define('Member', {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
membershipStatus: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'active',
|
||||
field: 'membership_status',
|
||||
},
|
||||
membershipType: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'regular',
|
||||
field: 'membership_type',
|
||||
},
|
||||
joinedOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'joined_on',
|
||||
},
|
||||
leftOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'left_on',
|
||||
},
|
||||
contributionGroupCode: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'contribution_group_code',
|
||||
},
|
||||
needsSepaMandate: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'needs_sepa_mandate',
|
||||
},
|
||||
sepaMandateReference: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'sepa_mandate_reference',
|
||||
},
|
||||
isArchived: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_archived',
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'archived_at',
|
||||
},
|
||||
archivedReason: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'archived_reason',
|
||||
},
|
||||
active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
|
||||
@@ -57,6 +57,9 @@ import BillingRun from './BillingRun.js';
|
||||
import BillingDocument from './BillingDocument.js';
|
||||
import BillingDocumentValue from './BillingDocumentValue.js';
|
||||
import BillingUserSetting from './BillingUserSetting.js';
|
||||
import ClubDocument from './ClubDocument.js';
|
||||
import ClubDocumentVersion from './ClubDocumentVersion.js';
|
||||
import ClubDocumentLink from './ClubDocumentLink.js';
|
||||
import FriendlyMatch from './FriendlyMatch.js';
|
||||
import FriendlyMatchShared from './FriendlyMatchShared.js';
|
||||
import FriendlyMatchInvitation from './FriendlyMatchInvitation.js';
|
||||
@@ -75,6 +78,7 @@ import ClubRequestNote from './ClubRequestNote.js';
|
||||
import ClubSepaMandate from './ClubSepaMandate.js';
|
||||
import ClubPaymentClaim from './ClubPaymentClaim.js';
|
||||
import ClubAccount from './ClubAccount.js';
|
||||
import ClubAccountTransaction from './ClubAccountTransaction.js';
|
||||
import ClubInvoiceParty from './ClubInvoiceParty.js';
|
||||
import ClubInvoice from './ClubInvoice.js';
|
||||
import ClubInvoiceItem from './ClubInvoiceItem.js';
|
||||
@@ -82,6 +86,13 @@ import ClubTask from './ClubTask.js';
|
||||
import ClubTaskSuppression from './ClubTaskSuppression.js';
|
||||
import ClubRole from './ClubRole.js';
|
||||
import ClubUserRole from './ClubUserRole.js';
|
||||
import ClubCommunicationThread from './ClubCommunicationThread.js';
|
||||
import ClubCommunicationMessage from './ClubCommunicationMessage.js';
|
||||
import ClubCommunicationRecipient from './ClubCommunicationRecipient.js';
|
||||
import ClubCommunicationDeliveryLog from './ClubCommunicationDeliveryLog.js';
|
||||
import ClubCommunicationTemplate from './ClubCommunicationTemplate.js';
|
||||
import ClubDistributionGroup from './ClubDistributionGroup.js';
|
||||
import ClubDistributionGroupMember from './ClubDistributionGroupMember.js';
|
||||
// Official tournaments relations
|
||||
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
|
||||
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
|
||||
@@ -193,6 +204,13 @@ ClubTeamMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
|
||||
ClubTeam.hasMany(TeamDocument, { foreignKey: 'clubTeamId', as: 'documents' });
|
||||
TeamDocument.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
|
||||
|
||||
Club.hasMany(ClubDocument, { foreignKey: 'clubId', as: 'documents' });
|
||||
ClubDocument.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubDocument.hasMany(ClubDocumentVersion, { foreignKey: 'documentId', as: 'versions' });
|
||||
ClubDocumentVersion.belongsTo(ClubDocument, { foreignKey: 'documentId', as: 'document' });
|
||||
ClubDocument.hasMany(ClubDocumentLink, { foreignKey: 'documentId', as: 'links' });
|
||||
ClubDocumentLink.belongsTo(ClubDocument, { foreignKey: 'documentId', as: 'document' });
|
||||
|
||||
Match.belongsTo(Location, { foreignKey: 'locationId', as: 'location' });
|
||||
Location.hasMany(Match, { foreignKey: 'locationId', as: 'matches' });
|
||||
|
||||
@@ -483,9 +501,15 @@ Club.hasMany(ClubPaymentClaim, { foreignKey: 'clubId', as: 'paymentClaims' });
|
||||
ClubPaymentClaim.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Member.hasMany(ClubPaymentClaim, { foreignKey: 'memberId', as: 'paymentClaims' });
|
||||
ClubPaymentClaim.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
|
||||
ClubPaymentClaim.hasMany(ClubAccountTransaction, { foreignKey: 'paymentClaimId', as: 'transactions' });
|
||||
ClubAccountTransaction.belongsTo(ClubPaymentClaim, { foreignKey: 'paymentClaimId', as: 'paymentClaim', constraints: false });
|
||||
|
||||
Club.hasMany(ClubAccount, { foreignKey: 'clubId', as: 'accounts' });
|
||||
ClubAccount.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Club.hasMany(ClubAccountTransaction, { foreignKey: 'clubId', as: 'accountTransactions' });
|
||||
ClubAccountTransaction.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubAccount.hasMany(ClubAccountTransaction, { foreignKey: 'accountId', as: 'transactions' });
|
||||
ClubAccountTransaction.belongsTo(ClubAccount, { foreignKey: 'accountId', as: 'account' });
|
||||
|
||||
Club.hasMany(ClubInvoiceParty, { foreignKey: 'clubId', as: 'invoiceParties' });
|
||||
ClubInvoiceParty.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
@@ -498,6 +522,10 @@ ClubInvoice.belongsTo(ClubAccount, { foreignKey: 'accountId', as: 'account', con
|
||||
ClubAccount.hasMany(ClubInvoice, { foreignKey: 'accountId', as: 'invoices' });
|
||||
User.hasMany(ClubInvoice, { foreignKey: 'createdByUserId', as: 'createdInvoices' });
|
||||
ClubInvoice.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
ClubInvoice.hasMany(ClubAccountTransaction, { foreignKey: 'invoiceId', as: 'transactions' });
|
||||
ClubAccountTransaction.belongsTo(ClubInvoice, { foreignKey: 'invoiceId', as: 'invoice', constraints: false });
|
||||
User.hasMany(ClubAccountTransaction, { foreignKey: 'createdByUserId', as: 'createdAccountTransactions' });
|
||||
ClubAccountTransaction.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
|
||||
ClubInvoice.hasMany(ClubInvoiceItem, { foreignKey: 'invoiceId', as: 'items' });
|
||||
ClubInvoiceItem.belongsTo(ClubInvoice, { foreignKey: 'invoiceId', as: 'invoice' });
|
||||
@@ -522,6 +550,45 @@ ClubUserRole.belongsTo(Club, { foreignKey: 'clubId', as: 'club', constraints: fa
|
||||
User.hasMany(ClubUserRole, { foreignKey: 'userId', as: 'clubRoleAssignments' });
|
||||
ClubUserRole.belongsTo(User, { foreignKey: 'userId', as: 'user', constraints: false });
|
||||
|
||||
Club.hasMany(ClubCommunicationThread, { foreignKey: 'clubId', as: 'communicationThreads' });
|
||||
ClubCommunicationThread.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
User.hasMany(ClubCommunicationThread, { foreignKey: 'createdByUserId', as: 'createdCommunicationThreads' });
|
||||
ClubCommunicationThread.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
ClubCommunicationThread.belongsTo(ClubDistributionGroup, { foreignKey: 'distributionGroupId', as: 'distributionGroup', constraints: false });
|
||||
ClubDistributionGroup.hasMany(ClubCommunicationThread, { foreignKey: 'distributionGroupId', as: 'threads' });
|
||||
ClubCommunicationThread.belongsTo(Member, { foreignKey: 'recipientMemberId', as: 'recipientMember', constraints: false });
|
||||
Member.hasMany(ClubCommunicationThread, { foreignKey: 'recipientMemberId', as: 'communicationThreads' });
|
||||
|
||||
Club.hasMany(ClubCommunicationMessage, { foreignKey: 'clubId', as: 'communicationMessages' });
|
||||
ClubCommunicationMessage.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubCommunicationThread.hasMany(ClubCommunicationMessage, { foreignKey: 'threadId', as: 'messages' });
|
||||
ClubCommunicationMessage.belongsTo(ClubCommunicationThread, { foreignKey: 'threadId', as: 'thread' });
|
||||
User.hasMany(ClubCommunicationMessage, { foreignKey: 'createdByUserId', as: 'createdCommunicationMessages' });
|
||||
ClubCommunicationMessage.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
Club.hasMany(ClubCommunicationTemplate, { foreignKey: 'clubId', as: 'communicationTemplates' });
|
||||
ClubCommunicationTemplate.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Club.hasMany(ClubCommunicationRecipient, { foreignKey: 'clubId', as: 'communicationRecipients' });
|
||||
ClubCommunicationRecipient.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubCommunicationThread.hasMany(ClubCommunicationRecipient, { foreignKey: 'threadId', as: 'recipients' });
|
||||
ClubCommunicationRecipient.belongsTo(ClubCommunicationThread, { foreignKey: 'threadId', as: 'thread' });
|
||||
Member.hasMany(ClubCommunicationRecipient, { foreignKey: 'memberId', as: 'communicationRecipientEntries' });
|
||||
ClubCommunicationRecipient.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
|
||||
Club.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'clubId', as: 'communicationDeliveryLogs' });
|
||||
ClubCommunicationDeliveryLog.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubCommunicationThread.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'threadId', as: 'deliveryLogs' });
|
||||
ClubCommunicationDeliveryLog.belongsTo(ClubCommunicationThread, { foreignKey: 'threadId', as: 'thread' });
|
||||
ClubCommunicationRecipient.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'recipientId', as: 'deliveryLogs' });
|
||||
ClubCommunicationDeliveryLog.belongsTo(ClubCommunicationRecipient, { foreignKey: 'recipientId', as: 'recipient', constraints: false });
|
||||
User.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'createdByUserId', as: 'createdCommunicationDeliveryLogs' });
|
||||
ClubCommunicationDeliveryLog.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
|
||||
Club.hasMany(ClubDistributionGroup, { foreignKey: 'clubId', as: 'distributionGroups' });
|
||||
ClubDistributionGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubDistributionGroup.hasMany(ClubDistributionGroupMember, { foreignKey: 'groupId', as: 'memberships' });
|
||||
ClubDistributionGroupMember.belongsTo(ClubDistributionGroup, { foreignKey: 'groupId', as: 'group' });
|
||||
Member.hasMany(ClubDistributionGroupMember, { foreignKey: 'memberId', as: 'distributionGroupMemberships' });
|
||||
ClubDistributionGroupMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
|
||||
|
||||
export {
|
||||
User,
|
||||
Log,
|
||||
@@ -548,6 +615,8 @@ export {
|
||||
ClubTeam,
|
||||
ClubTeamMember,
|
||||
TeamDocument,
|
||||
Season,
|
||||
Location,
|
||||
Group,
|
||||
GroupActivity,
|
||||
Tournament,
|
||||
@@ -558,6 +627,8 @@ export {
|
||||
TournamentResult,
|
||||
ExternalTournamentParticipant,
|
||||
TournamentPairing,
|
||||
TournamentStage,
|
||||
TournamentStageAdvancement,
|
||||
Accident,
|
||||
UserToken,
|
||||
OfficialTournament,
|
||||
@@ -579,6 +650,9 @@ export {
|
||||
BillingDocument,
|
||||
BillingDocumentValue,
|
||||
BillingUserSetting,
|
||||
ClubDocument,
|
||||
ClubDocumentVersion,
|
||||
ClubDocumentLink,
|
||||
FriendlyMatch,
|
||||
FriendlyMatchShared,
|
||||
FriendlyMatchInvitation,
|
||||
@@ -597,6 +671,7 @@ export {
|
||||
ClubSepaMandate,
|
||||
ClubPaymentClaim,
|
||||
ClubAccount,
|
||||
ClubAccountTransaction,
|
||||
ClubInvoiceParty,
|
||||
ClubInvoice,
|
||||
ClubInvoiceItem,
|
||||
@@ -604,4 +679,11 @@ export {
|
||||
ClubTaskSuppression,
|
||||
ClubRole,
|
||||
ClubUserRole,
|
||||
ClubCommunicationThread,
|
||||
ClubCommunicationMessage,
|
||||
ClubCommunicationRecipient,
|
||||
ClubCommunicationDeliveryLog,
|
||||
ClubCommunicationTemplate,
|
||||
ClubDistributionGroup,
|
||||
ClubDistributionGroupMember,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createClubCalendarEvent,
|
||||
deleteClubCalendarEvent,
|
||||
listClubCalendarEvents,
|
||||
updateClubCalendarEvent,
|
||||
} from '../controllers/calendarEventController.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -11,6 +12,7 @@ router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', listClubCalendarEvents);
|
||||
router.post('/:clubId', createClubCalendarEvent);
|
||||
router.put('/:clubId/:eventId', updateClubCalendarEvent);
|
||||
router.delete('/:clubId/:eventId', deleteClubCalendarEvent);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -7,10 +7,13 @@ const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', authorize('members', 'read'), clubAccountController.listClubAccounts);
|
||||
router.post('/:clubId', authorize('members', 'write'), clubAccountController.createClubAccount);
|
||||
router.put('/:clubId/:accountId', authorize('members', 'write'), clubAccountController.updateClubAccount);
|
||||
router.patch('/:clubId/:accountId/status', authorize('members', 'write'), clubAccountController.updateClubAccountStatus);
|
||||
router.delete('/:clubId/:accountId', authorize('members', 'write'), clubAccountController.deleteClubAccount);
|
||||
router.get('/:clubId', authorize('finance_accounts', 'read'), clubAccountController.listClubAccounts);
|
||||
router.post('/:clubId', authorize('finance_accounts', 'write'), clubAccountController.createClubAccount);
|
||||
router.put('/:clubId/:accountId', authorize('finance_accounts', 'write'), clubAccountController.updateClubAccount);
|
||||
router.patch('/:clubId/:accountId/status', authorize('finance_accounts', 'write'), clubAccountController.updateClubAccountStatus);
|
||||
router.delete('/:clubId/:accountId', authorize('finance_accounts', 'write'), clubAccountController.deleteClubAccount);
|
||||
router.post('/:clubId/transactions', authorize('finance_accounts', 'write'), clubAccountController.createTransaction);
|
||||
router.put('/:clubId/transactions/:transactionId', authorize('finance_accounts', 'write'), clubAccountController.updateTransaction);
|
||||
router.delete('/:clubId/transactions/:transactionId', authorize('finance_accounts', 'write'), clubAccountController.deleteTransaction);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -6,6 +6,6 @@ import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.get('/:clubId', authorize('settings', 'read'), clubArchiveController.getClubArchive);
|
||||
router.get('/:clubId', authorize('archive', 'read'), clubArchiveController.getClubArchive);
|
||||
|
||||
export default router;
|
||||
|
||||
24
backend/routes/clubCommunicationRoutes.js
Normal file
24
backend/routes/clubCommunicationRoutes.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
import clubCommunicationController from '../controllers/clubCommunicationController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', authorize('communication', 'read'), clubCommunicationController.list);
|
||||
router.post('/:clubId/threads', authorize('communication', 'write'), clubCommunicationController.createThread);
|
||||
router.put('/:clubId/threads/:threadId', authorize('communication', 'write'), clubCommunicationController.updateThread);
|
||||
router.delete('/:clubId/threads/:threadId', authorize('communication', 'write'), clubCommunicationController.deleteThread);
|
||||
router.post('/:clubId/threads/:threadId/messages', authorize('communication', 'write'), clubCommunicationController.addMessage);
|
||||
router.patch('/:clubId/threads/:threadId/send', authorize('communication', 'write'), clubCommunicationController.sendThread);
|
||||
|
||||
router.post('/:clubId/groups', authorize('communication', 'write'), clubCommunicationController.createGroup);
|
||||
router.put('/:clubId/groups/:groupId', authorize('communication', 'write'), clubCommunicationController.updateGroup);
|
||||
router.delete('/:clubId/groups/:groupId', authorize('communication', 'write'), clubCommunicationController.deleteGroup);
|
||||
router.post('/:clubId/templates', authorize('communication', 'write'), clubCommunicationController.createTemplate);
|
||||
router.put('/:clubId/templates/:templateId', authorize('communication', 'write'), clubCommunicationController.updateTemplate);
|
||||
router.delete('/:clubId/templates/:templateId', authorize('communication', 'write'), clubCommunicationController.deleteTemplate);
|
||||
|
||||
export default router;
|
||||
19
backend/routes/clubDocumentRoutes.js
Normal file
19
backend/routes/clubDocumentRoutes.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
import clubDocumentController, {
|
||||
uploadMiddleware,
|
||||
} from '../controllers/clubDocumentController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', authorize('settings', 'read'), clubDocumentController.listClubDocuments);
|
||||
router.get('/:clubId/:documentId/download', authorize('settings', 'read'), clubDocumentController.downloadClubDocument);
|
||||
router.post('/:clubId', authorize('settings', 'write'), uploadMiddleware, clubDocumentController.createClubDocument);
|
||||
router.put('/:clubId/:documentId', authorize('settings', 'write'), uploadMiddleware, clubDocumentController.updateClubDocument);
|
||||
router.patch('/:clubId/:documentId/archive', authorize('settings', 'write'), clubDocumentController.archiveClubDocument);
|
||||
router.delete('/:clubId/:documentId', authorize('settings', 'write'), clubDocumentController.deleteClubDocument);
|
||||
|
||||
export default router;
|
||||
@@ -7,15 +7,15 @@ const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', authorize('members', 'read'), clubInvoiceController.listClubInvoices);
|
||||
router.get('/:clubId', authorize('finance_invoices', 'read'), clubInvoiceController.listClubInvoices);
|
||||
|
||||
router.post('/:clubId/parties', authorize('members', 'write'), clubInvoiceController.createInvoiceParty);
|
||||
router.put('/:clubId/parties/:partyId', authorize('members', 'write'), clubInvoiceController.updateInvoiceParty);
|
||||
router.delete('/:clubId/parties/:partyId', authorize('members', 'write'), clubInvoiceController.deleteInvoiceParty);
|
||||
router.post('/:clubId/parties', authorize('finance_invoices', 'write'), clubInvoiceController.createInvoiceParty);
|
||||
router.put('/:clubId/parties/:partyId', authorize('finance_invoices', 'write'), clubInvoiceController.updateInvoiceParty);
|
||||
router.delete('/:clubId/parties/:partyId', authorize('finance_invoices', 'write'), clubInvoiceController.deleteInvoiceParty);
|
||||
|
||||
router.post('/:clubId', authorize('members', 'write'), clubInvoiceController.createInvoice);
|
||||
router.put('/:clubId/:invoiceId', authorize('members', 'write'), clubInvoiceController.updateInvoice);
|
||||
router.patch('/:clubId/:invoiceId/status', authorize('members', 'write'), clubInvoiceController.updateInvoiceStatus);
|
||||
router.delete('/:clubId/:invoiceId', authorize('members', 'write'), clubInvoiceController.deleteInvoice);
|
||||
router.post('/:clubId', authorize('finance_invoices', 'write'), clubInvoiceController.createInvoice);
|
||||
router.put('/:clubId/:invoiceId', authorize('finance_invoices', 'write'), clubInvoiceController.updateInvoice);
|
||||
router.patch('/:clubId/:invoiceId/status', authorize('finance_invoices', 'write'), clubInvoiceController.updateInvoiceStatus);
|
||||
router.delete('/:clubId/:invoiceId', authorize('finance_invoices', 'write'), clubInvoiceController.deleteInvoice);
|
||||
|
||||
export default router;
|
||||
|
||||
17
backend/routes/clubPaymentClaimRoutes.js
Normal file
17
backend/routes/clubPaymentClaimRoutes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import express from 'express';
|
||||
import clubPaymentClaimController from '../controllers/clubPaymentClaimController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', authorize('finance_accounts', 'read'), clubPaymentClaimController.list);
|
||||
router.post('/:clubId', authorize('finance_accounts', 'write'), clubPaymentClaimController.create);
|
||||
router.put('/:clubId/:claimId', authorize('finance_accounts', 'write'), clubPaymentClaimController.update);
|
||||
router.patch('/:clubId/:claimId/payment', authorize('finance_accounts', 'write'), clubPaymentClaimController.registerPayment);
|
||||
router.patch('/:clubId/:claimId/status', authorize('finance_accounts', 'write'), clubPaymentClaimController.updateStatus);
|
||||
router.delete('/:clubId/:claimId', authorize('finance_accounts', 'write'), clubPaymentClaimController.delete);
|
||||
|
||||
export default router;
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:clubId', authenticate, authorize('members', 'read'), listClubRequests);
|
||||
router.post('/:clubId', authenticate, authorize('members', 'write'), createClubRequest);
|
||||
router.put('/:clubId/:requestId', authenticate, authorize('members', 'write'), updateClubRequest);
|
||||
router.patch('/:clubId/:requestId/status', authenticate, authorize('members', 'write'), updateClubRequestStatus);
|
||||
router.post('/:clubId/:requestId/notes', authenticate, authorize('members', 'write'), addClubRequestNote);
|
||||
router.get('/:clubId', authenticate, authorize('requests', 'read'), listClubRequests);
|
||||
router.post('/:clubId', authenticate, authorize('requests', 'write'), createClubRequest);
|
||||
router.put('/:clubId/:requestId', authenticate, authorize('requests', 'write'), updateClubRequest);
|
||||
router.patch('/:clubId/:requestId/status', authenticate, authorize('requests', 'write'), updateClubRequestStatus);
|
||||
router.post('/:clubId/:requestId/notes', authenticate, authorize('requests', 'write'), addClubRequestNote);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:clubId', authenticate, authorize('members', 'read'), listClubTasks);
|
||||
router.post('/:clubId', authenticate, authorize('members', 'write'), createClubTask);
|
||||
router.post('/:clubId/materialize', authenticate, authorize('members', 'write'), materializeAutomatedClubTasks);
|
||||
router.post('/:clubId/dismiss-suggestion', authenticate, authorize('members', 'write'), dismissAutomatedClubTaskSuggestion);
|
||||
router.put('/:clubId/:taskId', authenticate, authorize('members', 'write'), updateClubTask);
|
||||
router.patch('/:clubId/:taskId/status', authenticate, authorize('members', 'write'), updateClubTaskStatus);
|
||||
router.delete('/:clubId/:taskId', authenticate, authorize('members', 'write'), deleteClubTask);
|
||||
router.get('/:clubId', authenticate, authorize('tasks', 'read'), listClubTasks);
|
||||
router.post('/:clubId', authenticate, authorize('tasks', 'write'), createClubTask);
|
||||
router.post('/:clubId/materialize', authenticate, authorize('tasks', 'write'), materializeAutomatedClubTasks);
|
||||
router.post('/:clubId/dismiss-suggestion', authenticate, authorize('tasks', 'write'), dismissAutomatedClubTaskSuggestion);
|
||||
router.put('/:clubId/:taskId', authenticate, authorize('tasks', 'write'), updateClubTask);
|
||||
router.patch('/:clubId/:taskId/status', authenticate, authorize('tasks', 'write'), updateClubTaskStatus);
|
||||
router.delete('/:clubId/:taskId', authenticate, authorize('tasks', 'write'), deleteClubTask);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest,
|
||||
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation
|
||||
, FriendlyMatchShared, FriendlyMatchInvitation
|
||||
, CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole
|
||||
, CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubAccountTransaction, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole, ClubCommunicationThread, ClubCommunicationMessage, ClubCommunicationRecipient, ClubCommunicationDeliveryLog, ClubCommunicationTemplate, ClubDistributionGroup, ClubDistributionGroupMember
|
||||
} from './models/index.js';
|
||||
import authRoutes from './routes/authRoutes.js';
|
||||
import clubRoutes from './routes/clubRoutes.js';
|
||||
@@ -75,6 +75,9 @@ import clubStatisticsRoutes from './routes/clubStatisticsRoutes.js';
|
||||
import clubArchiveRoutes from './routes/clubArchiveRoutes.js';
|
||||
import clubAccountRoutes from './routes/clubAccountRoutes.js';
|
||||
import clubInvoiceRoutes from './routes/clubInvoiceRoutes.js';
|
||||
import clubPaymentClaimRoutes from './routes/clubPaymentClaimRoutes.js';
|
||||
import clubCommunicationRoutes from './routes/clubCommunicationRoutes.js';
|
||||
import clubDocumentRoutes from './routes/clubDocumentRoutes.js';
|
||||
import schedulerService from './services/schedulerService.js';
|
||||
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
|
||||
import HttpError from './exceptions/HttpError.js';
|
||||
@@ -382,6 +385,9 @@ app.use('/api/club-statistics', clubStatisticsRoutes);
|
||||
app.use('/api/club-archive', clubArchiveRoutes);
|
||||
app.use('/api/club-accounts', clubAccountRoutes);
|
||||
app.use('/api/club-invoices', clubInvoiceRoutes);
|
||||
app.use('/api/club-payment-claims', clubPaymentClaimRoutes);
|
||||
app.use('/api/club-communication', clubCommunicationRoutes);
|
||||
app.use('/api/club-documents', clubDocumentRoutes);
|
||||
|
||||
// Middleware für dynamischen kanonischen Tag (vor express.static)
|
||||
const setCanonicalTag = (req, res, next) => {
|
||||
@@ -588,9 +594,17 @@ app.use((err, req, res, next) => {
|
||||
await safeSync(ClubRole);
|
||||
await safeSync(ClubUserRole);
|
||||
await safeSync(ClubAccount);
|
||||
await safeSync(ClubAccountTransaction);
|
||||
await safeSync(ClubInvoiceParty);
|
||||
await safeSync(ClubInvoice);
|
||||
await safeSync(ClubInvoiceItem);
|
||||
await safeSync(ClubDistributionGroup);
|
||||
await safeSync(ClubDistributionGroupMember);
|
||||
await safeSync(ClubCommunicationThread);
|
||||
await safeSync(ClubCommunicationMessage);
|
||||
await safeSync(ClubCommunicationRecipient);
|
||||
await safeSync(ClubCommunicationDeliveryLog);
|
||||
await safeSync(ClubCommunicationTemplate);
|
||||
await safeSync(Log);
|
||||
await safeSync(Member);
|
||||
await safeSync(DiaryDate);
|
||||
|
||||
@@ -17,25 +17,31 @@ class CalendarEventService {
|
||||
});
|
||||
}
|
||||
|
||||
async createClubEvent(userToken, clubId, payload) {
|
||||
async createClubEvent(userToken, clubId, payload, userId = null) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const title = String(payload?.title || '').trim();
|
||||
if (!title) throw new HttpError('Titel fehlt', 400);
|
||||
const startDate = this.normalizeDate(payload?.startDate);
|
||||
const endDate = this.normalizeDate(payload?.endDate || payload?.startDate);
|
||||
if (!startDate || !endDate) throw new HttpError('Ungültiges Datum', 400);
|
||||
if (startDate > endDate) throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400);
|
||||
const normalized = this.normalizePayload(payload);
|
||||
this.validatePayload(normalized);
|
||||
|
||||
return await CalendarEvent.create({
|
||||
clubId,
|
||||
title,
|
||||
startDate,
|
||||
endDate,
|
||||
category: payload?.category ? String(payload.category).trim().slice(0, 64) : null,
|
||||
notes: payload?.notes ? String(payload.notes).trim() : null,
|
||||
...normalized,
|
||||
organizerUserId: Number(userId || payload?.organizerUserId || 0) || null,
|
||||
});
|
||||
}
|
||||
|
||||
async updateClubEvent(userToken, clubId, eventId, payload, userId = null) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
|
||||
if (!event) throw new HttpError('Event nicht gefunden', 404);
|
||||
const normalized = this.normalizePayload(payload);
|
||||
this.validatePayload(normalized);
|
||||
await event.update({
|
||||
...normalized,
|
||||
organizerUserId: Number(userId || payload?.organizerUserId || event.organizerUserId || 0) || null,
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
async deleteClubEvent(userToken, clubId, eventId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
|
||||
@@ -54,6 +60,34 @@ class CalendarEventService {
|
||||
const text = String(date || '').slice(0, 10);
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
|
||||
}
|
||||
|
||||
normalizePayload(payload = {}) {
|
||||
return {
|
||||
title: String(payload?.title || '').trim().slice(0, 255),
|
||||
eventType: ['club_event', 'meeting', 'tournament', 'social', 'workshop', 'other'].includes(payload?.eventType)
|
||||
? payload.eventType
|
||||
: 'club_event',
|
||||
status: ['planning', 'invited', 'confirmed', 'done', 'cancelled'].includes(payload?.status)
|
||||
? payload.status
|
||||
: 'planning',
|
||||
description: payload?.description ? String(payload.description).trim() : null,
|
||||
location: payload?.location ? String(payload.location).trim().slice(0, 255) : null,
|
||||
startDate: this.normalizeDate(payload?.startDate),
|
||||
endDate: this.normalizeDate(payload?.endDate || payload?.startDate),
|
||||
registrationDeadline: this.normalizeDate(payload?.registrationDeadline),
|
||||
category: payload?.category ? String(payload.category).trim().slice(0, 64) : null,
|
||||
notes: payload?.notes ? String(payload.notes).trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
validatePayload(payload) {
|
||||
if (!payload.title) throw new HttpError('Titel fehlt', 400);
|
||||
if (!payload.startDate || !payload.endDate) throw new HttpError('Ungültiges Datum', 400);
|
||||
if (payload.startDate > payload.endDate) throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400);
|
||||
if (payload.registrationDeadline && payload.registrationDeadline > payload.startDate) {
|
||||
throw new HttpError('Anmeldefrist darf nicht nach dem Startdatum liegen', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new CalendarEventService();
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Op } from 'sequelize';
|
||||
import { Op, Transaction } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import ClubAccount from '../models/ClubAccount.js';
|
||||
import ClubAccountTransaction from '../models/ClubAccountTransaction.js';
|
||||
import { ClubPaymentClaim, Member } from '../models/index.js';
|
||||
import clubPaymentClaimService from './clubPaymentClaimService.js';
|
||||
|
||||
const TRANSACTION_DIRECTIONS = new Set(['credit', 'debit']);
|
||||
const TRANSACTION_BOOKING_TYPES = new Set(['manual', 'invoice', 'adjustment', 'payment_claim']);
|
||||
const TRANSACTION_STATUSES = new Set(['planned', 'booked', 'cancelled']);
|
||||
|
||||
const ACCOUNT_TYPES = new Set(['bank', 'cash', 'virtual']);
|
||||
const ACCOUNT_USAGE_TYPES = new Set(['general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash']);
|
||||
@@ -44,6 +51,159 @@ function normalizePayload(payload = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTransactionPayload(payload = {}) {
|
||||
return {
|
||||
accountId: Number(payload.accountId) || null,
|
||||
invoiceId: Number(payload.invoiceId) || null,
|
||||
paymentClaimId: Number(payload.paymentClaimId) || null,
|
||||
direction: TRANSACTION_DIRECTIONS.has(payload.direction) ? payload.direction : 'credit',
|
||||
bookingType: TRANSACTION_BOOKING_TYPES.has(payload.bookingType) ? payload.bookingType : 'manual',
|
||||
status: TRANSACTION_STATUSES.has(payload.status) ? payload.status : 'booked',
|
||||
bookingDate: trimText(payload.bookingDate, 10),
|
||||
valueDate: trimText(payload.valueDate, 10),
|
||||
amountCents: Number.parseInt(payload.amountCents, 10) || 0,
|
||||
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
|
||||
reference: trimText(payload.reference, 255),
|
||||
notes: trimText(payload.notes),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractClaimIdFromReference(reference) {
|
||||
const normalized = normalizeSearchText(reference);
|
||||
const match = normalized.match(/(?:forderung|beitrag|claim)\s*(?:nr|nummer|no)?\s*(\d+)/);
|
||||
if (match) return Number(match[1]) || null;
|
||||
const hashMatch = normalized.match(/#\s*(\d+)/);
|
||||
if (hashMatch) return Number(hashMatch[1]) || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function transactionMatchesClaimReference(transactionText, claim, member) {
|
||||
if (!transactionText) return false;
|
||||
const tokens = [
|
||||
claim?.id ? `forderung ${claim.id}` : '',
|
||||
claim?.id ? `claim ${claim.id}` : '',
|
||||
member?.firstName || '',
|
||||
member?.lastName || '',
|
||||
member?.email || '',
|
||||
member?.sepaMandateReference || member?.sepa_mandate_reference || '',
|
||||
member?.memberNumber || member?.member_number || '',
|
||||
claim?.notes || '',
|
||||
]
|
||||
.map(normalizeSearchText)
|
||||
.filter(Boolean);
|
||||
return tokens.some((token) => token && transactionText.includes(token));
|
||||
}
|
||||
|
||||
function scorePaymentClaimMatch(transaction, claim) {
|
||||
const text = normalizeSearchText([transaction.reference, transaction.notes].filter(Boolean).join(' '));
|
||||
if (!text) return 0;
|
||||
|
||||
let score = 0;
|
||||
const member = claim.member || {};
|
||||
const explicitClaimId = extractClaimIdFromReference(transaction.reference);
|
||||
if (explicitClaimId && Number(explicitClaimId) === Number(claim.id)) {
|
||||
score += 120;
|
||||
} else if (text.includes(`forderung ${claim.id}`) || text.includes(`claim ${claim.id}`) || text.includes(`beitrag ${claim.id}`)) {
|
||||
score += 90;
|
||||
}
|
||||
|
||||
if (transactionMatchesClaimReference(text, claim, member)) {
|
||||
score += 40;
|
||||
}
|
||||
|
||||
const amountCents = Number(transaction.amountCents || 0);
|
||||
const remainingAmountCents = Math.max(0, Number(claim.amountCents || 0) - Number(claim.paidAmountCents || 0));
|
||||
if (amountCents === remainingAmountCents) {
|
||||
score += 35;
|
||||
} else if (amountCents < remainingAmountCents) {
|
||||
score += 15;
|
||||
} else {
|
||||
score -= 20;
|
||||
}
|
||||
|
||||
const bookingDate = transaction.bookingDate ? new Date(transaction.bookingDate) : null;
|
||||
const dueDate = claim.dueOn ? new Date(claim.dueOn) : null;
|
||||
if (bookingDate && dueDate && !Number.isNaN(bookingDate.getTime()) && !Number.isNaN(dueDate.getTime())) {
|
||||
const dayDistance = Math.abs(Math.floor((bookingDate.getTime() - dueDate.getTime()) / 86400000));
|
||||
if (dayDistance <= 7) score += 20;
|
||||
else if (dayDistance <= 30) score += 10;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
async function findBestPaymentClaimMatch(clubId, transaction, dbTransaction = null) {
|
||||
if (!transaction || transaction.direction !== 'credit' || transaction.status !== 'booked' || Number(transaction.amountCents || 0) <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (transaction.invoiceId || transaction.paymentClaimId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitClaimId = extractClaimIdFromReference(transaction.reference);
|
||||
if (explicitClaimId) {
|
||||
const explicitClaim = await ClubPaymentClaim.findOne({
|
||||
where: {
|
||||
id: explicitClaimId,
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
},
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
if (explicitClaim && Number(transaction.amountCents || 0) <= Math.max(0, Number(explicitClaim.amountCents || 0) - Number(explicitClaim.paidAmountCents || 0))) {
|
||||
return explicitClaim;
|
||||
}
|
||||
}
|
||||
|
||||
const claims = await ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
currencyCode: transaction.currencyCode || 'EUR',
|
||||
},
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
order: [['dueOn', 'ASC'], ['updatedAt', 'ASC']],
|
||||
});
|
||||
|
||||
let bestMatch = null;
|
||||
let bestScore = 0;
|
||||
let secondScore = 0;
|
||||
for (const claim of claims) {
|
||||
const score = scorePaymentClaimMatch(transaction, claim);
|
||||
if (score > bestScore) {
|
||||
secondScore = bestScore;
|
||||
bestScore = score;
|
||||
bestMatch = claim;
|
||||
} else if (score > secondScore) {
|
||||
secondScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMatch) return null;
|
||||
if (bestScore < 60) return null;
|
||||
if (bestScore < secondScore + 20) return null;
|
||||
if (Number(transaction.amountCents || 0) > Math.max(0, Number(bestMatch.amountCents || 0) - Number(bestMatch.paidAmountCents || 0))) {
|
||||
return null;
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function validatePayload(payload) {
|
||||
if (!payload.name) {
|
||||
const error = new Error('Kontobezeichnung ist erforderlich.');
|
||||
@@ -70,6 +230,26 @@ function validatePayload(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateTransactionPayload(payload) {
|
||||
if (!payload.accountId) {
|
||||
const error = new Error('Für eine Kontenbewegung muss ein Konto gewählt werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!payload.bookingDate) {
|
||||
const error = new Error('Buchungsdatum ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(Number(payload.amountCents)) || Number(payload.amountCents) <= 0) {
|
||||
const error = new Error('Der Betrag muss größer als 0 sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSingleDefault(clubId, accountId, transaction) {
|
||||
await ClubAccount.update(
|
||||
{ isDefault: false },
|
||||
@@ -112,13 +292,182 @@ async function ensureFallbackDefault(clubId, transaction) {
|
||||
|
||||
class ClubAccountService {
|
||||
async listClubAccounts(clubId) {
|
||||
return ClubAccount.findAll({
|
||||
where: { clubId },
|
||||
const [accounts, transactions] = await Promise.all([
|
||||
ClubAccount.findAll({
|
||||
where: { clubId },
|
||||
order: [
|
||||
['isDefault', 'DESC'],
|
||||
['status', 'ASC'],
|
||||
['sortOrder', 'ASC'],
|
||||
['name', 'ASC'],
|
||||
],
|
||||
}),
|
||||
ClubAccountTransaction.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
],
|
||||
order: [['bookingDate', 'DESC'], ['createdAt', 'DESC']],
|
||||
limit: 250,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
|
||||
async createTransaction(clubId, userId, payload) {
|
||||
const normalized = normalizeTransactionPayload(payload);
|
||||
validateTransactionPayload(normalized);
|
||||
|
||||
const account = await ClubAccount.findOne({
|
||||
where: { id: normalized.accountId, clubId },
|
||||
});
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sequelize.transaction(async (dbTransaction) => {
|
||||
let matchedClaim = null;
|
||||
if (normalized.paymentClaimId) {
|
||||
matchedClaim = await ClubPaymentClaim.findOne({
|
||||
where: {
|
||||
id: normalized.paymentClaimId,
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
},
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
transaction: dbTransaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
if (!matchedClaim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
matchedClaim = await findBestPaymentClaimMatch(clubId, normalized, dbTransaction);
|
||||
}
|
||||
|
||||
const transactionPayload = {
|
||||
clubId,
|
||||
createdByUserId: userId || null,
|
||||
...normalized,
|
||||
paymentClaimId: matchedClaim ? matchedClaim.id : normalized.paymentClaimId,
|
||||
bookingType: matchedClaim ? 'payment_claim' : normalized.bookingType,
|
||||
};
|
||||
|
||||
const transaction = await ClubAccountTransaction.create(transactionPayload, { transaction: dbTransaction });
|
||||
|
||||
if (matchedClaim) {
|
||||
await clubPaymentClaimService.applyPaymentToClaim(
|
||||
clubId,
|
||||
matchedClaim,
|
||||
{
|
||||
amountCents: Number(transaction.amountCents || 0),
|
||||
},
|
||||
dbTransaction
|
||||
);
|
||||
await clubPaymentClaimService.reconcileFromTransactions(clubId, matchedClaim.id, dbTransaction);
|
||||
}
|
||||
|
||||
return transaction.reload({
|
||||
include: [
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
],
|
||||
transaction: dbTransaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateTransaction(clubId, transactionId, payload) {
|
||||
const transactionRow = await ClubAccountTransaction.findOne({
|
||||
where: { id: transactionId, clubId },
|
||||
});
|
||||
if (!transactionRow) {
|
||||
const error = new Error('Kontenbewegung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (transactionRow.bookingType !== 'manual') {
|
||||
const error = new Error('Automatisch erzeugte Kontenbewegungen können nicht manuell bearbeitet werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeTransactionPayload(payload);
|
||||
validateTransactionPayload(normalized);
|
||||
const previousPaymentClaimId = Number(transactionRow.paymentClaimId || 0) || null;
|
||||
|
||||
const account = await ClubAccount.findOne({
|
||||
where: { id: normalized.accountId, clubId },
|
||||
});
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await transactionRow.update(normalized);
|
||||
|
||||
const claimIdsToReconcile = new Set([previousPaymentClaimId, normalized.paymentClaimId || null].filter(Boolean));
|
||||
for (const claimId of claimIdsToReconcile) {
|
||||
await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId);
|
||||
}
|
||||
|
||||
return transactionRow.reload({
|
||||
include: [
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTransaction(clubId, transactionId) {
|
||||
const transactionRow = await ClubAccountTransaction.findOne({
|
||||
where: { id: transactionId, clubId },
|
||||
});
|
||||
if (!transactionRow) {
|
||||
const error = new Error('Kontenbewegung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (transactionRow.bookingType !== 'manual') {
|
||||
const error = new Error('Automatisch erzeugte Kontenbewegungen können nicht gelöscht werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const claimId = Number(transactionRow.paymentClaimId || 0) || null;
|
||||
await transactionRow.destroy();
|
||||
if (claimId) {
|
||||
await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async listAccountTransactions(clubId, accountId = null) {
|
||||
const where = { clubId };
|
||||
if (accountId) {
|
||||
where.accountId = accountId;
|
||||
}
|
||||
|
||||
return ClubAccountTransaction.findAll({
|
||||
where,
|
||||
include: [
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
],
|
||||
order: [
|
||||
['isDefault', 'DESC'],
|
||||
['status', 'ASC'],
|
||||
['sortOrder', 'ASC'],
|
||||
['name', 'ASC'],
|
||||
['bookingDate', 'DESC'],
|
||||
['createdAt', 'DESC'],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
828
backend/services/clubCommunicationService.js
Normal file
828
backend/services/clubCommunicationService.js
Normal file
@@ -0,0 +1,828 @@
|
||||
import sequelize from '../database.js';
|
||||
import {
|
||||
ClubCommunicationThread,
|
||||
ClubCommunicationMessage,
|
||||
ClubCommunicationRecipient,
|
||||
ClubCommunicationDeliveryLog,
|
||||
ClubCommunicationTemplate,
|
||||
ClubDistributionGroup,
|
||||
ClubDistributionGroupMember,
|
||||
ClubSepaMandate,
|
||||
Member,
|
||||
MemberContact,
|
||||
User,
|
||||
} from '../models/index.js';
|
||||
import { sendClubCommunicationEmail } from './emailService.js';
|
||||
|
||||
const THREAD_TYPES = new Set(['direct', 'group', 'broadcast']);
|
||||
const THREAD_STATUSES = new Set(['draft', 'scheduled', 'sent', 'archived']);
|
||||
const MESSAGE_TYPES = new Set(['message', 'note']);
|
||||
const DIRECTIONS = new Set(['outbound', 'internal', 'inbound']);
|
||||
const GROUP_TYPES = new Set(['manual', 'training_group', 'team', 'custom']);
|
||||
|
||||
function trimText(value, maxLength = null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return null;
|
||||
return maxLength ? normalized.slice(0, maxLength) : normalized;
|
||||
}
|
||||
|
||||
function normalizeDate(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeId(value) {
|
||||
const numeric = Number(value);
|
||||
return Number.isInteger(numeric) && numeric > 0 ? numeric : null;
|
||||
}
|
||||
|
||||
function normalizeThreadPayload(payload = {}) {
|
||||
return {
|
||||
threadType: THREAD_TYPES.has(payload.threadType) ? payload.threadType : 'direct',
|
||||
subject: trimText(payload.subject, 255),
|
||||
status: THREAD_STATUSES.has(payload.status) ? payload.status : 'draft',
|
||||
recipientMemberId: normalizeId(payload.recipientMemberId),
|
||||
distributionGroupId: normalizeId(payload.distributionGroupId),
|
||||
scheduledAt: normalizeDate(payload.scheduledAt),
|
||||
recipientFilters: normalizeRecipientFilters(payload.recipientFilters),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGroupPayload(payload = {}) {
|
||||
const memberIds = Array.isArray(payload.memberIds)
|
||||
? [...new Set(payload.memberIds.map(normalizeId).filter(Boolean))]
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: trimText(payload.name, 255),
|
||||
description: trimText(payload.description),
|
||||
groupType: GROUP_TYPES.has(payload.groupType) ? payload.groupType : 'custom',
|
||||
memberIds,
|
||||
filterDefinition: normalizeRecipientFilters(payload.filterDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessagePayload(payload = {}) {
|
||||
return {
|
||||
body: trimText(payload.body),
|
||||
messageType: MESSAGE_TYPES.has(payload.messageType) ? payload.messageType : 'message',
|
||||
direction: DIRECTIONS.has(payload.direction) ? payload.direction : 'outbound',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRecipientFilters(payload = {}) {
|
||||
return {
|
||||
activeOnly: payload?.activeOnly !== false,
|
||||
onlyWithEmail: Boolean(payload?.onlyWithEmail),
|
||||
missingEmail: Boolean(payload?.missingEmail),
|
||||
requiresSepaMandate: Boolean(payload?.requiresSepaMandate),
|
||||
missingSepaMandate: Boolean(payload?.missingSepaMandate),
|
||||
testMembersOnly: Boolean(payload?.testMembersOnly),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplatePayload(payload = {}) {
|
||||
return {
|
||||
name: trimText(payload.name, 160),
|
||||
category: trimText(payload.category, 64) || 'general',
|
||||
subjectTemplate: trimText(payload.subjectTemplate, 255),
|
||||
bodyTemplate: trimText(payload.bodyTemplate),
|
||||
variablesHint: trimText(payload.variablesHint),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureThreadTargets(payload) {
|
||||
if (!payload.subject) {
|
||||
const error = new Error('Betreff ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (payload.threadType === 'direct' && !payload.recipientMemberId) {
|
||||
const error = new Error('Für Einzelnachrichten muss ein Mitglied ausgewählt werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (payload.threadType === 'group' && !payload.distributionGroupId) {
|
||||
const error = new Error('Für Gruppen-Nachrichten muss eine Verteilergruppe ausgewählt werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMemberBelongsToClub(clubId, memberId) {
|
||||
if (!memberId) return;
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId } });
|
||||
if (!member) {
|
||||
const error = new Error('Mitglied wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGroupBelongsToClub(clubId, groupId) {
|
||||
if (!groupId) return null;
|
||||
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
|
||||
if (!group) {
|
||||
const error = new Error('Verteilergruppe wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function getMemberDisplayName(member) {
|
||||
return [member?.firstName, member?.lastName].filter(Boolean).join(' ').trim() || `Mitglied ${member?.id || ''}`.trim();
|
||||
}
|
||||
|
||||
function getPrimaryEmail(member) {
|
||||
const contacts = Array.isArray(member?.contacts) ? member.contacts : [];
|
||||
const emailContacts = contacts.filter((contact) => contact?.type === 'email' && contact?.value);
|
||||
const primaryContact = emailContacts.find((contact) => contact.isPrimary) || emailContacts[0];
|
||||
return primaryContact?.value || member?.email || null;
|
||||
}
|
||||
|
||||
function applyRecipientFilters(members, filters = {}, memberIdsWithMandate = new Set()) {
|
||||
const normalized = normalizeRecipientFilters(filters);
|
||||
return (Array.isArray(members) ? members : []).filter((member) => {
|
||||
if (normalized.activeOnly && member?.active === false) return false;
|
||||
if (normalized.testMembersOnly && !member?.testMembership) return false;
|
||||
|
||||
const email = trimText(getPrimaryEmail(member), 255);
|
||||
if (normalized.onlyWithEmail && !email) return false;
|
||||
if (normalized.missingEmail && email) return false;
|
||||
|
||||
const hasMandate = memberIdsWithMandate.has(Number(member?.id));
|
||||
if (normalized.requiresSepaMandate && !hasMandate) return false;
|
||||
if (normalized.missingSepaMandate && hasMandate) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadActiveSepaMemberIds(clubId) {
|
||||
const mandates = await ClubSepaMandate.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active',
|
||||
revokedAt: null,
|
||||
},
|
||||
attributes: ['memberId'],
|
||||
});
|
||||
|
||||
return new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function looksLikeHtml(value = '') {
|
||||
return /<\/?[a-z][\s\S]*>/i.test(String(value || ''));
|
||||
}
|
||||
|
||||
function stripHtml(value = '') {
|
||||
return String(value || '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/p>\s*<p>/gi, '\n\n')
|
||||
.replace(/<\/div>\s*<div>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '\n- ')
|
||||
.replace(/<\/(p|div|li|h[1-6]|blockquote)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderMessageText(body) {
|
||||
if (looksLikeHtml(body)) {
|
||||
return stripHtml(body);
|
||||
}
|
||||
return String(body || '').trim();
|
||||
}
|
||||
|
||||
function renderMessageHtml(body) {
|
||||
if (looksLikeHtml(body)) {
|
||||
return `<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#1f2937;">${String(body || '')}</div>`;
|
||||
}
|
||||
return `<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#1f2937;white-space:pre-wrap;">${escapeHtml(body).replace(/\n/g, '<br>')}</div>`;
|
||||
}
|
||||
|
||||
function getLatestOutboundMessage(messages = []) {
|
||||
const outboundMessages = messages.filter((message) => message?.messageType === 'message' && message?.direction === 'outbound');
|
||||
return outboundMessages.at(-1) || null;
|
||||
}
|
||||
|
||||
function classifyDeliveryError(error) {
|
||||
const responseCode = Number(error?.responseCode || error?.response?.statusCode || 0) || null;
|
||||
const code = String(error?.code || '').trim() || `SMTP_${responseCode || 'UNKNOWN'}`;
|
||||
const message = trimText(error?.message, 500) || 'Unbekannter Versandfehler.';
|
||||
const retryableCodes = new Set([
|
||||
'ETIMEDOUT',
|
||||
'ESOCKET',
|
||||
'ECONNECTION',
|
||||
'ECONNRESET',
|
||||
'EAI_AGAIN',
|
||||
'ENOTFOUND',
|
||||
'EMESSAGE',
|
||||
'EMAIL_CONFIG_MISSING',
|
||||
]);
|
||||
const retryableResponseCodes = new Set([421, 425, 429, 450, 451, 452]);
|
||||
const retryable = retryableCodes.has(code) || (responseCode ? retryableResponseCodes.has(responseCode) : false);
|
||||
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
responseCode,
|
||||
};
|
||||
}
|
||||
|
||||
function canAttemptDelivery(recipient) {
|
||||
const hasEmail = !!trimText(recipient?.emailSnapshot, 255);
|
||||
if (!hasEmail) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Keine E-Mail-Adresse hinterlegt.',
|
||||
retryable: false,
|
||||
code: 'MISSING_EMAIL',
|
||||
};
|
||||
}
|
||||
|
||||
if (recipient?.deliveryStatus === 'sent') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Empfänger wurde bereits erfolgreich beliefert.',
|
||||
retryable: false,
|
||||
code: 'ALREADY_SENT',
|
||||
};
|
||||
}
|
||||
|
||||
if (recipient?.deliveryStatus === 'failed' && recipient?.retryable === false) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: recipient?.errorMessage || 'Versandfehler ist nicht erneut versendbar.',
|
||||
retryable: false,
|
||||
code: recipient?.errorCode || 'NON_RETRYABLE_FAILURE',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
reason: '',
|
||||
retryable: true,
|
||||
code: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildRecipientRowsForThread(clubId, threadPayload) {
|
||||
let members = [];
|
||||
const memberIdsWithMandate = await loadActiveSepaMemberIds(clubId);
|
||||
|
||||
if (threadPayload.threadType === 'direct' && threadPayload.recipientMemberId) {
|
||||
members = await Member.findAll({
|
||||
where: { clubId, id: threadPayload.recipientMemberId },
|
||||
include: [{ model: MemberContact, as: 'contacts', required: false }],
|
||||
});
|
||||
} else if (threadPayload.threadType === 'group' && threadPayload.distributionGroupId) {
|
||||
const group = await ClubDistributionGroup.findOne({
|
||||
where: { id: threadPayload.distributionGroupId, clubId },
|
||||
});
|
||||
const memberships = await ClubDistributionGroupMember.findAll({
|
||||
where: { groupId: threadPayload.distributionGroupId },
|
||||
include: [{
|
||||
model: Member,
|
||||
as: 'member',
|
||||
required: true,
|
||||
where: { clubId, active: true },
|
||||
include: [{ model: MemberContact, as: 'contacts', required: false }],
|
||||
}],
|
||||
});
|
||||
members = applyRecipientFilters(
|
||||
memberships.map((membership) => membership.member).filter(Boolean),
|
||||
threadPayload.recipientFilters || group?.filterDefinition || {},
|
||||
memberIdsWithMandate
|
||||
);
|
||||
} else if (threadPayload.threadType === 'broadcast') {
|
||||
members = await Member.findAll({
|
||||
where: { clubId, active: true },
|
||||
include: [{ model: MemberContact, as: 'contacts', required: false }],
|
||||
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
|
||||
});
|
||||
members = applyRecipientFilters(members, threadPayload.recipientFilters || {}, memberIdsWithMandate);
|
||||
}
|
||||
|
||||
const uniqueMembers = new Map();
|
||||
members.forEach((member) => {
|
||||
const memberId = Number(member.id);
|
||||
if (!uniqueMembers.has(memberId)) {
|
||||
uniqueMembers.set(memberId, member);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueMembers.values()).map((member) => {
|
||||
const emailSnapshot = getPrimaryEmail(member);
|
||||
return {
|
||||
clubId,
|
||||
memberId: Number(member.id),
|
||||
recipientName: getMemberDisplayName(member),
|
||||
emailSnapshot: emailSnapshot || null,
|
||||
deliveryStatus: emailSnapshot ? 'pending' : 'failed',
|
||||
deliveredAt: null,
|
||||
lastAttemptAt: null,
|
||||
attemptCount: 0,
|
||||
retryable: false,
|
||||
errorCode: emailSnapshot ? null : 'MISSING_EMAIL',
|
||||
transportMessageId: null,
|
||||
errorMessage: emailSnapshot ? null : 'Keine E-Mail-Adresse hinterlegt.',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function replaceRecipientsForThread(thread, threadPayload, transaction) {
|
||||
const recipients = await buildRecipientRowsForThread(thread.clubId, threadPayload);
|
||||
await ClubCommunicationDeliveryLog.destroy({
|
||||
where: { threadId: thread.id, clubId: thread.clubId },
|
||||
transaction,
|
||||
});
|
||||
await ClubCommunicationRecipient.destroy({
|
||||
where: { threadId: thread.id, clubId: thread.clubId },
|
||||
transaction,
|
||||
});
|
||||
if (recipients.length > 0) {
|
||||
await ClubCommunicationRecipient.bulkCreate(
|
||||
recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
threadId: thread.id,
|
||||
})),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ClubCommunicationService {
|
||||
async listClubCommunication(clubId) {
|
||||
const [threads, groups, members, templates] = await Promise.all([
|
||||
ClubCommunicationThread.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{ model: ClubCommunicationMessage, as: 'messages', required: false, include: [{ model: User, as: 'createdByUser', required: false }] },
|
||||
{ model: ClubCommunicationRecipient, as: 'recipients', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
{
|
||||
model: ClubCommunicationDeliveryLog,
|
||||
as: 'deliveryLogs',
|
||||
required: false,
|
||||
include: [
|
||||
{ model: ClubCommunicationRecipient, as: 'recipient', required: false },
|
||||
{ model: User, as: 'createdByUser', required: false },
|
||||
],
|
||||
},
|
||||
{ model: ClubDistributionGroup, as: 'distributionGroup', required: false },
|
||||
{ model: Member, as: 'recipientMember', required: false },
|
||||
{ model: User, as: 'createdByUser', required: false },
|
||||
],
|
||||
order: [
|
||||
['updatedAt', 'DESC'],
|
||||
[{ model: ClubCommunicationMessage, as: 'messages' }, 'createdAt', 'ASC'],
|
||||
[{ model: ClubCommunicationRecipient, as: 'recipients' }, 'recipientName', 'ASC'],
|
||||
[{ model: ClubCommunicationDeliveryLog, as: 'deliveryLogs' }, 'createdAt', 'DESC'],
|
||||
],
|
||||
}),
|
||||
ClubDistributionGroup.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{
|
||||
model: ClubDistributionGroupMember,
|
||||
as: 'memberships',
|
||||
required: false,
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
},
|
||||
],
|
||||
order: [['isSystemGroup', 'DESC'], ['name', 'ASC']],
|
||||
}),
|
||||
Member.findAll({
|
||||
where: { clubId, active: true },
|
||||
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
|
||||
}),
|
||||
ClubCommunicationTemplate.findAll({
|
||||
where: { clubId },
|
||||
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
|
||||
}),
|
||||
]);
|
||||
|
||||
return { threads, groups, members, templates };
|
||||
}
|
||||
|
||||
async createThread(clubId, userId, payload) {
|
||||
const normalized = normalizeThreadPayload(payload);
|
||||
ensureThreadTargets(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.recipientMemberId);
|
||||
await ensureGroupBelongsToClub(clubId, normalized.distributionGroupId);
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
const thread = await ClubCommunicationThread.create({
|
||||
clubId,
|
||||
createdByUserId: userId || null,
|
||||
...normalized,
|
||||
}, { transaction });
|
||||
await replaceRecipientsForThread(thread, normalized, transaction);
|
||||
return thread;
|
||||
});
|
||||
}
|
||||
|
||||
async updateThread(clubId, threadId, payload) {
|
||||
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeThreadPayload(payload);
|
||||
ensureThreadTargets(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.recipientMemberId);
|
||||
await ensureGroupBelongsToClub(clubId, normalized.distributionGroupId);
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await thread.update({
|
||||
...normalized,
|
||||
sentAt: normalized.status === 'sent' ? (thread.sentAt || new Date()) : (normalized.status === 'archived' ? thread.sentAt : null),
|
||||
}, { transaction });
|
||||
await replaceRecipientsForThread(thread, normalized, transaction);
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
|
||||
async addMessage(clubId, threadId, userId, payload) {
|
||||
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeMessagePayload(payload);
|
||||
if (!normalized.body) {
|
||||
const error = new Error('Nachrichtentext ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = await ClubCommunicationMessage.create({
|
||||
threadId: thread.id,
|
||||
clubId,
|
||||
createdByUserId: userId || null,
|
||||
...normalized,
|
||||
});
|
||||
|
||||
await thread.update({
|
||||
status: thread.status === 'archived' ? 'archived' : thread.status,
|
||||
sentAt: normalized.direction === 'outbound' && normalized.messageType === 'message' ? (thread.sentAt || new Date()) : thread.sentAt,
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async sendThread(clubId, threadId, userId = null) {
|
||||
const thread = await ClubCommunicationThread.findOne({
|
||||
where: { id: threadId, clubId },
|
||||
include: [
|
||||
{
|
||||
model: ClubCommunicationMessage,
|
||||
as: 'messages',
|
||||
required: false,
|
||||
order: [['createdAt', 'ASC']],
|
||||
},
|
||||
{
|
||||
model: ClubCommunicationRecipient,
|
||||
as: 'recipients',
|
||||
required: false,
|
||||
order: [['recipientName', 'ASC']],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const latestMessage = getLatestOutboundMessage(thread.messages || []);
|
||||
if (!latestMessage?.body) {
|
||||
const error = new Error('Zum Versand wird mindestens eine ausgehende Nachricht benötigt.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recipients = Array.isArray(thread.recipients) ? thread.recipients : [];
|
||||
let attemptedAny = false;
|
||||
let processedAny = false;
|
||||
|
||||
if (recipients.length === 0) {
|
||||
const error = new Error('Für diesen Kommunikationsvorgang sind keine Empfänger vorhanden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const eligibility = canAttemptDelivery(recipient);
|
||||
if (!eligibility.allowed) {
|
||||
if (!recipient.attemptCount && eligibility.code === 'MISSING_EMAIL') {
|
||||
const now = new Date();
|
||||
processedAny = true;
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await recipient.update({
|
||||
deliveryStatus: 'failed',
|
||||
lastAttemptAt: now,
|
||||
attemptCount: Number(recipient.attemptCount || 0) + 1,
|
||||
retryable: false,
|
||||
errorCode: eligibility.code,
|
||||
errorMessage: eligibility.reason,
|
||||
deliveredAt: null,
|
||||
transportMessageId: null,
|
||||
}, { transaction });
|
||||
|
||||
await ClubCommunicationDeliveryLog.create({
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
createdByUserId: userId || null,
|
||||
status: 'failed',
|
||||
attemptNo: Number(recipient.attemptCount || 0) + 1,
|
||||
retryable: false,
|
||||
errorCode: eligibility.code,
|
||||
errorMessage: eligibility.reason,
|
||||
}, { transaction });
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
attemptedAny = true;
|
||||
processedAny = true;
|
||||
const nextAttemptNo = Number(recipient.attemptCount || 0) + 1;
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const result = await sendClubCommunicationEmail({
|
||||
to: recipient.emailSnapshot,
|
||||
subject: thread.subject,
|
||||
text: renderMessageText(latestMessage.body),
|
||||
html: renderMessageHtml(latestMessage.body),
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await recipient.update({
|
||||
deliveryStatus: 'sent',
|
||||
deliveredAt: now,
|
||||
lastAttemptAt: now,
|
||||
attemptCount: nextAttemptNo,
|
||||
retryable: false,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
transportMessageId: trimText(result?.messageId, 255),
|
||||
}, { transaction });
|
||||
|
||||
await ClubCommunicationDeliveryLog.create({
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
createdByUserId: userId || null,
|
||||
status: 'sent',
|
||||
attemptNo: nextAttemptNo,
|
||||
retryable: false,
|
||||
transportMessageId: trimText(result?.messageId, 255),
|
||||
transportResponse: trimText(result?.response, 1000),
|
||||
}, { transaction });
|
||||
});
|
||||
} catch (sendError) {
|
||||
const classified = classifyDeliveryError(sendError);
|
||||
console.error('[sendClubCommunicationThread] delivery failed', {
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
email: recipient.emailSnapshot,
|
||||
code: classified.code,
|
||||
retryable: classified.retryable,
|
||||
message: classified.message,
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await recipient.update({
|
||||
deliveryStatus: 'failed',
|
||||
deliveredAt: null,
|
||||
lastAttemptAt: now,
|
||||
attemptCount: nextAttemptNo,
|
||||
retryable: classified.retryable,
|
||||
errorCode: classified.code,
|
||||
errorMessage: classified.message,
|
||||
transportMessageId: null,
|
||||
}, { transaction });
|
||||
|
||||
await ClubCommunicationDeliveryLog.create({
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
createdByUserId: userId || null,
|
||||
status: 'failed',
|
||||
attemptNo: nextAttemptNo,
|
||||
retryable: classified.retryable,
|
||||
errorCode: classified.code,
|
||||
errorMessage: classified.message,
|
||||
transportResponse: trimText(sendError?.response, 1000),
|
||||
}, { transaction });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!processedAny) {
|
||||
const error = new Error('Es gibt aktuell keine erneut versendbaren Empfänger.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await thread.update({
|
||||
status: 'sent',
|
||||
sentAt: thread.sentAt || new Date(),
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
|
||||
async deleteThread(clubId, threadId) {
|
||||
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await ClubCommunicationMessage.destroy({
|
||||
where: { threadId: thread.id, clubId },
|
||||
transaction,
|
||||
});
|
||||
await ClubCommunicationRecipient.destroy({
|
||||
where: { threadId: thread.id, clubId },
|
||||
transaction,
|
||||
});
|
||||
await ClubCommunicationDeliveryLog.destroy({
|
||||
where: { threadId: thread.id, clubId },
|
||||
transaction,
|
||||
});
|
||||
await thread.destroy({ transaction });
|
||||
});
|
||||
}
|
||||
|
||||
async createGroup(clubId, payload) {
|
||||
const normalized = normalizeGroupPayload(payload);
|
||||
if (!normalized.name) {
|
||||
const error = new Error('Gruppenname ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
const group = await ClubDistributionGroup.create({
|
||||
clubId,
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
groupType: normalized.groupType,
|
||||
filterDefinition: normalized.filterDefinition,
|
||||
}, { transaction });
|
||||
|
||||
if (normalized.memberIds.length > 0) {
|
||||
await ClubDistributionGroupMember.bulkCreate(
|
||||
normalized.memberIds.map((memberId) => ({ groupId: group.id, memberId })),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
return group;
|
||||
});
|
||||
}
|
||||
|
||||
async updateGroup(clubId, groupId, payload) {
|
||||
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
|
||||
if (!group) {
|
||||
const error = new Error('Verteilergruppe wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeGroupPayload(payload);
|
||||
if (!normalized.name) {
|
||||
const error = new Error('Gruppenname ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await group.update({
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
groupType: normalized.groupType,
|
||||
filterDefinition: normalized.filterDefinition,
|
||||
}, { transaction });
|
||||
|
||||
await ClubDistributionGroupMember.destroy({
|
||||
where: { groupId: group.id },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (normalized.memberIds.length > 0) {
|
||||
await ClubDistributionGroupMember.bulkCreate(
|
||||
normalized.memberIds.map((memberId) => ({ groupId: group.id, memberId })),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
async deleteGroup(clubId, groupId) {
|
||||
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
|
||||
if (!group) {
|
||||
const error = new Error('Verteilergruppe wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const linkedThreads = await ClubCommunicationThread.count({
|
||||
where: { clubId, distributionGroupId: group.id },
|
||||
});
|
||||
if (linkedThreads > 0) {
|
||||
const error = new Error('Verteilergruppe kann nicht gelöscht werden, weil noch Kommunikationsvorgänge darauf verweisen.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await ClubDistributionGroupMember.destroy({ where: { groupId: group.id }, transaction });
|
||||
await group.destroy({ transaction });
|
||||
});
|
||||
}
|
||||
|
||||
async createTemplate(clubId, payload) {
|
||||
const normalized = normalizeTemplatePayload(payload);
|
||||
if (!normalized.name || !normalized.bodyTemplate) {
|
||||
const error = new Error('Vorlagenname und Nachrichtentext sind erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return ClubCommunicationTemplate.create({
|
||||
clubId,
|
||||
...normalized,
|
||||
});
|
||||
}
|
||||
|
||||
async updateTemplate(clubId, templateId, payload) {
|
||||
const template = await ClubCommunicationTemplate.findOne({
|
||||
where: { id: templateId, clubId },
|
||||
});
|
||||
if (!template) {
|
||||
const error = new Error('Vorlage wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (template.isSystemTemplate) {
|
||||
const error = new Error('Systemvorlagen können nicht bearbeitet werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeTemplatePayload(payload);
|
||||
if (!normalized.name || !normalized.bodyTemplate) {
|
||||
const error = new Error('Vorlagenname und Nachrichtentext sind erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await template.update(normalized);
|
||||
return template;
|
||||
}
|
||||
|
||||
async deleteTemplate(clubId, templateId) {
|
||||
const template = await ClubCommunicationTemplate.findOne({
|
||||
where: { id: templateId, clubId },
|
||||
});
|
||||
if (!template) {
|
||||
const error = new Error('Vorlage wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (template.isSystemTemplate) {
|
||||
const error = new Error('Systemvorlagen können nicht gelöscht werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await template.destroy();
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubCommunicationService();
|
||||
361
backend/services/clubDocumentService.js
Normal file
361
backend/services/clubDocumentService.js
Normal file
@@ -0,0 +1,361 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import sequelize from '../database.js';
|
||||
import Club from '../models/Club.js';
|
||||
import ClubDocument from '../models/ClubDocument.js';
|
||||
import ClubDocumentVersion from '../models/ClubDocumentVersion.js';
|
||||
import ClubDocumentLink from '../models/ClubDocumentLink.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DOCUMENT_TYPES = new Set(['satzung', 'protokoll', 'nachweis', 'formular', 'vertrag', 'rechnung', 'other']);
|
||||
const DOCUMENT_STATUSES = new Set(['active', 'draft', 'archived', 'obsolete']);
|
||||
const VISIBILITY_SCOPES = new Set(['board', 'finance', 'trainers', 'all']);
|
||||
|
||||
function trimText(value, maxLength = null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return null;
|
||||
return maxLength ? normalized.slice(0, maxLength) : normalized;
|
||||
}
|
||||
|
||||
function normalizePayload(payload = {}) {
|
||||
return {
|
||||
documentType: DOCUMENT_TYPES.has(payload.documentType) ? payload.documentType : 'other',
|
||||
title: trimText(payload.title, 255),
|
||||
description: trimText(payload.description),
|
||||
status: DOCUMENT_STATUSES.has(payload.status) ? payload.status : 'active',
|
||||
visibilityScope: VISIBILITY_SCOPES.has(payload.visibilityScope) ? payload.visibilityScope : 'board',
|
||||
changeNote: trimText(payload.changeNote),
|
||||
linkedEntityType: trimText(payload.linkedEntityType, 32),
|
||||
linkedEntityId: payload.linkedEntityId ? Number(payload.linkedEntityId) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileName(value = '') {
|
||||
return String(value || 'document').replace(/[^a-zA-Z0-9._-]+/g, '_');
|
||||
}
|
||||
|
||||
function getStorageDir(clubId) {
|
||||
return path.join(__dirname, '..', 'uploads', 'club-documents', String(clubId));
|
||||
}
|
||||
|
||||
function ensureStorageDir(clubId) {
|
||||
const uploadDir = getStorageDir(clubId);
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
return uploadDir;
|
||||
}
|
||||
|
||||
function checksumFile(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
hash.update(fileBuffer);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function moveUploadedFile(tempPath, destinationPath) {
|
||||
try {
|
||||
fs.renameSync(tempPath, destinationPath);
|
||||
} catch (error) {
|
||||
if (error.code === 'EXDEV') {
|
||||
fs.copyFileSync(tempPath, destinationPath);
|
||||
fs.unlinkSync(tempPath);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDocumentSnapshot(document, versions = [], links = []) {
|
||||
const orderedVersions = [...versions].sort((a, b) => Number(b.versionNo || 0) - Number(a.versionNo || 0));
|
||||
const latestVersion = orderedVersions[0] || null;
|
||||
return {
|
||||
...document.get({ plain: true }),
|
||||
versionCount: versions.length,
|
||||
latestVersion: latestVersion ? latestVersion.get({ plain: true }) : null,
|
||||
versions: orderedVersions.map((version) => version.get({ plain: true })),
|
||||
links: links.map((link) => link.get({ plain: true })),
|
||||
linkedEntityCount: links.length,
|
||||
};
|
||||
}
|
||||
|
||||
class ClubDocumentService {
|
||||
async listClubDocuments(clubId, filters = {}) {
|
||||
const [club, documents] = await Promise.all([
|
||||
Club.findByPk(clubId, { attributes: ['id'] }),
|
||||
ClubDocument.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{ model: ClubDocumentVersion, as: 'versions', required: false },
|
||||
{ model: ClubDocumentLink, as: 'links', required: false },
|
||||
],
|
||||
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!club) {
|
||||
const error = new Error('Verein wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const search = trimText(filters.search)?.toLowerCase() || '';
|
||||
const typeFilter = trimText(filters.documentType);
|
||||
const statusFilter = trimText(filters.status);
|
||||
const visibilityFilter = trimText(filters.visibilityScope);
|
||||
|
||||
return documents
|
||||
.filter((document) => {
|
||||
if (typeFilter && document.documentType !== typeFilter) return false;
|
||||
if (statusFilter && document.status !== statusFilter) return false;
|
||||
if (visibilityFilter && document.visibilityScope !== visibilityFilter) return false;
|
||||
if (!search) return true;
|
||||
|
||||
const haystack = [
|
||||
document.title,
|
||||
document.description,
|
||||
document.documentType,
|
||||
document.status,
|
||||
document.visibilityScope,
|
||||
...(document.versions || []).map((version) => version.fileName),
|
||||
...(document.links || []).map((link) => `${link.linkedEntityType} ${link.linkedEntityId}`),
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
return haystack.includes(search);
|
||||
})
|
||||
.map((document) => buildDocumentSnapshot(document, document.versions || [], document.links || []));
|
||||
}
|
||||
|
||||
async createClubDocument(clubId, userId, payload = {}, file = null) {
|
||||
const normalized = normalizePayload(payload);
|
||||
if (!normalized.title) {
|
||||
const error = new Error('Bitte einen Dokumenttitel angeben.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!file) {
|
||||
const error = new Error('Eine Datei ist für das neue Dokument erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const club = await Club.findByPk(clubId, { attributes: ['id'] });
|
||||
if (!club) {
|
||||
const error = new Error('Verein wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const uploadDir = ensureStorageDir(clubId);
|
||||
const safeFileName = normalizeFileName(file.originalname);
|
||||
const storageFileName = `${Date.now()}_${safeFileName}`;
|
||||
const storagePath = path.join(uploadDir, storageFileName);
|
||||
moveUploadedFile(file.path, storagePath);
|
||||
|
||||
const checksum = checksumFile(storagePath);
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const document = await ClubDocument.create({
|
||||
clubId,
|
||||
documentType: normalized.documentType,
|
||||
title: normalized.title,
|
||||
description: normalized.description,
|
||||
status: normalized.status,
|
||||
visibilityScope: normalized.visibilityScope,
|
||||
ownerUserId: userId || null,
|
||||
currentVersionNo: 1,
|
||||
}, { transaction });
|
||||
|
||||
const version = await ClubDocumentVersion.create({
|
||||
documentId: document.id,
|
||||
versionNo: 1,
|
||||
fileName: safeFileName,
|
||||
storagePath,
|
||||
mimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
checksumSha256: checksum,
|
||||
uploadedByUserId: userId || null,
|
||||
changeNote: normalized.changeNote,
|
||||
}, { transaction });
|
||||
|
||||
if (normalized.linkedEntityType && normalized.linkedEntityId) {
|
||||
await ClubDocumentLink.create({
|
||||
documentId: document.id,
|
||||
linkedEntityType: normalized.linkedEntityType,
|
||||
linkedEntityId: normalized.linkedEntityId,
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return this.getClubDocumentById(clubId, document.id);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
if (fs.existsSync(storagePath)) {
|
||||
fs.unlinkSync(storagePath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateClubDocument(clubId, documentId, userId, payload = {}, file = null) {
|
||||
const normalized = normalizePayload(payload);
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [
|
||||
{ model: ClubDocumentVersion, as: 'versions', required: false },
|
||||
{ model: ClubDocumentLink, as: 'links', required: false },
|
||||
],
|
||||
});
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
let uploadedStoragePath = null;
|
||||
try {
|
||||
await document.update({
|
||||
title: normalized.title || document.title,
|
||||
description: normalized.description,
|
||||
status: normalized.status,
|
||||
visibilityScope: normalized.visibilityScope,
|
||||
documentType: normalized.documentType,
|
||||
}, { transaction });
|
||||
|
||||
if (file) {
|
||||
const uploadDir = ensureStorageDir(clubId);
|
||||
const nextVersionNo = Number(document.currentVersionNo || 1) + 1;
|
||||
const safeFileName = normalizeFileName(file.originalname);
|
||||
const storageFileName = `${Date.now()}_v${nextVersionNo}_${safeFileName}`;
|
||||
const storagePath = path.join(uploadDir, storageFileName);
|
||||
moveUploadedFile(file.path, storagePath);
|
||||
uploadedStoragePath = storagePath;
|
||||
const checksum = checksumFile(storagePath);
|
||||
|
||||
await ClubDocumentVersion.create({
|
||||
documentId: document.id,
|
||||
versionNo: nextVersionNo,
|
||||
fileName: safeFileName,
|
||||
storagePath,
|
||||
mimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
checksumSha256: checksum,
|
||||
uploadedByUserId: userId || null,
|
||||
changeNote: normalized.changeNote,
|
||||
}, { transaction });
|
||||
|
||||
await document.update({
|
||||
currentVersionNo: nextVersionNo,
|
||||
archivedAt: normalized.status === 'archived' ? new Date() : null,
|
||||
status: normalized.status,
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
if (normalized.linkedEntityType && normalized.linkedEntityId) {
|
||||
await ClubDocumentLink.findOrCreate({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
linkedEntityType: normalized.linkedEntityType,
|
||||
linkedEntityId: normalized.linkedEntityId,
|
||||
},
|
||||
defaults: {
|
||||
documentId: document.id,
|
||||
linkedEntityType: normalized.linkedEntityType,
|
||||
linkedEntityId: normalized.linkedEntityId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return this.getClubDocumentById(clubId, document.id);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
if (uploadedStoragePath && fs.existsSync(uploadedStoragePath)) {
|
||||
fs.unlinkSync(uploadedStoragePath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async archiveClubDocument(clubId, documentId) {
|
||||
const document = await ClubDocument.findOne({ where: { id: documentId, clubId } });
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await document.update({
|
||||
status: 'archived',
|
||||
archivedAt: new Date(),
|
||||
});
|
||||
return this.getClubDocumentById(clubId, document.id);
|
||||
}
|
||||
|
||||
async deleteClubDocument(clubId, documentId) {
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [{ model: ClubDocumentVersion, as: 'versions', required: false }],
|
||||
});
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const version of document.versions || []) {
|
||||
if (version.storagePath && fs.existsSync(version.storagePath)) {
|
||||
fs.unlinkSync(version.storagePath);
|
||||
}
|
||||
}
|
||||
await ClubDocumentLink.destroy({ where: { documentId } });
|
||||
await ClubDocumentVersion.destroy({ where: { documentId } });
|
||||
await document.destroy();
|
||||
return true;
|
||||
}
|
||||
|
||||
async getClubDocumentById(clubId, documentId) {
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [
|
||||
{ model: ClubDocumentVersion, as: 'versions', required: false },
|
||||
{ model: ClubDocumentLink, as: 'links', required: false },
|
||||
],
|
||||
});
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
return buildDocumentSnapshot(document, document.versions || [], document.links || []);
|
||||
}
|
||||
|
||||
async getDocumentDownload(clubId, documentId, versionNo = null) {
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [{ model: ClubDocumentVersion, as: 'versions', required: false }],
|
||||
});
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const versions = document.versions || [];
|
||||
const selectedVersion = versionNo
|
||||
? versions.find((version) => Number(version.versionNo) === Number(versionNo))
|
||||
: [...versions].sort((a, b) => Number(b.versionNo || 0) - Number(a.versionNo || 0))[0];
|
||||
|
||||
if (!selectedVersion) {
|
||||
const error = new Error('Dokumentversion wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { document: document.get({ plain: true }), version: selectedVersion.get({ plain: true }) };
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubDocumentService();
|
||||
@@ -1,5 +1,6 @@
|
||||
import sequelize from '../database.js';
|
||||
import Club from '../models/Club.js';
|
||||
import ClubAccountTransaction from '../models/ClubAccountTransaction.js';
|
||||
import ClubInvoice from '../models/ClubInvoice.js';
|
||||
import ClubInvoiceItem from '../models/ClubInvoiceItem.js';
|
||||
import ClubInvoiceParty from '../models/ClubInvoiceParty.js';
|
||||
@@ -34,6 +35,10 @@ function normalizePartyPayload(payload = {}) {
|
||||
return {
|
||||
name: trimText(payload.name, 255),
|
||||
partyType: PARTY_TYPES.has(payload.partyType) ? payload.partyType : 'customer',
|
||||
status: trimText(payload.status, 32) || 'active',
|
||||
contractReference: trimText(payload.contractReference, 120),
|
||||
validFrom: normalizeDate(payload.validFrom),
|
||||
validTo: normalizeDate(payload.validTo),
|
||||
contactName: trimText(payload.contactName, 255),
|
||||
email: trimText(payload.email, 255),
|
||||
phone: trimText(payload.phone, 80),
|
||||
@@ -76,7 +81,6 @@ function normalizeInvoicePayload(payload = {}) {
|
||||
invoiceDirection: INVOICE_DIRECTIONS.has(payload.invoiceDirection) ? payload.invoiceDirection : 'outgoing',
|
||||
invoiceType: INVOICE_TYPES.has(payload.invoiceType) ? payload.invoiceType : 'other',
|
||||
status: INVOICE_STATUSES.has(payload.status) ? payload.status : 'draft',
|
||||
invoiceNumber: trimText(payload.invoiceNumber, 64),
|
||||
externalReference: trimText(payload.externalReference, 255),
|
||||
partyId: payload.partyId ? Number(payload.partyId) : null,
|
||||
accountId: payload.accountId ? Number(payload.accountId) : null,
|
||||
@@ -117,13 +121,73 @@ function summarizeItems(items) {
|
||||
}
|
||||
|
||||
function buildInvoiceNumber(prefix, nextNumber, referenceDate = new Date()) {
|
||||
const year = referenceDate.getFullYear();
|
||||
const parsedReferenceDate = referenceDate instanceof Date ? referenceDate : new Date(referenceDate);
|
||||
const year = Number.isNaN(parsedReferenceDate.getTime()) ? new Date().getFullYear() : parsedReferenceDate.getFullYear();
|
||||
const normalizedPrefix = String(prefix || '').trim().toUpperCase();
|
||||
const paddedCounter = String(Math.max(1, Number.parseInt(nextNumber, 10) || 1)).padStart(4, '0');
|
||||
return normalizedPrefix ? `${normalizedPrefix}-${year}-${paddedCounter}` : `${year}-${paddedCounter}`;
|
||||
}
|
||||
|
||||
async function generateNextInvoiceNumber(clubId, invoiceDirection, transaction) {
|
||||
function deriveInvoiceTransactionData(invoice) {
|
||||
if (!invoice?.accountId || invoice.status === 'draft') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const amountCents = Math.max(0, Number.parseInt(invoice.grossAmountCents, 10) || 0);
|
||||
if (!amountCents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bookingDate = invoice.paidOn || invoice.issuedOn || invoice.dueOn || new Date().toISOString().slice(0, 10);
|
||||
const status = invoice.status === 'paid'
|
||||
? 'booked'
|
||||
: ['issued', 'partially_paid'].includes(invoice.status)
|
||||
? 'planned'
|
||||
: 'cancelled';
|
||||
|
||||
return {
|
||||
clubId: invoice.clubId,
|
||||
accountId: invoice.accountId,
|
||||
invoiceId: invoice.id,
|
||||
direction: invoice.invoiceDirection === 'incoming' ? 'debit' : 'credit',
|
||||
bookingType: 'invoice',
|
||||
status,
|
||||
bookingDate,
|
||||
valueDate: bookingDate,
|
||||
amountCents,
|
||||
currencyCode: invoice.currencyCode || 'EUR',
|
||||
reference: invoice.invoiceNumber || invoice.externalReference || `Rechnung ${invoice.id}`,
|
||||
notes: invoice.description || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function syncInvoiceAccountTransaction(invoice, transaction) {
|
||||
const transactionData = deriveInvoiceTransactionData(invoice);
|
||||
const existingTransaction = await ClubAccountTransaction.findOne({
|
||||
where: {
|
||||
clubId: invoice.clubId,
|
||||
invoiceId: invoice.id,
|
||||
bookingType: 'invoice',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!transactionData) {
|
||||
if (existingTransaction) {
|
||||
await existingTransaction.destroy({ transaction });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existingTransaction) {
|
||||
await existingTransaction.update(transactionData, { transaction });
|
||||
return existingTransaction;
|
||||
}
|
||||
|
||||
return ClubAccountTransaction.create(transactionData, { transaction });
|
||||
}
|
||||
|
||||
async function generateNextInvoiceNumber(clubId, invoiceDirection, issuedOn, transaction) {
|
||||
const club = await Club.findByPk(clubId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
@@ -138,10 +202,22 @@ async function generateNextInvoiceNumber(clubId, invoiceDirection, transaction)
|
||||
const isIncoming = invoiceDirection === 'incoming';
|
||||
const prefixField = isIncoming ? 'incomingInvoicePrefix' : 'outgoingInvoicePrefix';
|
||||
const nextNumberField = isIncoming ? 'incomingInvoiceNextNumber' : 'outgoingInvoiceNextNumber';
|
||||
const invoiceNumber = buildInvoiceNumber(club[prefixField], club[nextNumberField]);
|
||||
const referenceDate = issuedOn || new Date();
|
||||
let nextNumber = Math.max(1, Number.parseInt(club[nextNumberField], 10) || 1);
|
||||
let invoiceNumber = buildInvoiceNumber(club[prefixField], nextNumber, referenceDate);
|
||||
|
||||
// Falls der Nummernkreis manuell zurückgesetzt wurde, wird bis zur nächsten freien Nummer weitergezählt.
|
||||
while (await ClubInvoice.count({
|
||||
where: { clubId, invoiceNumber },
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
})) {
|
||||
nextNumber += 1;
|
||||
invoiceNumber = buildInvoiceNumber(club[prefixField], nextNumber, referenceDate);
|
||||
}
|
||||
|
||||
await club.update({
|
||||
[nextNumberField]: Math.max(1, Number.parseInt(club[nextNumberField], 10) || 1) + 1,
|
||||
[nextNumberField]: nextNumber + 1,
|
||||
}, { transaction });
|
||||
|
||||
return invoiceNumber;
|
||||
@@ -240,8 +316,12 @@ class ClubInvoiceService {
|
||||
const totals = summarizeItems(normalized.items);
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
const invoiceNumber = normalized.invoiceNumber
|
||||
|| await generateNextInvoiceNumber(clubId, normalized.invoiceDirection, transaction);
|
||||
const invoiceNumber = await generateNextInvoiceNumber(
|
||||
clubId,
|
||||
normalized.invoiceDirection,
|
||||
normalized.issuedOn,
|
||||
transaction
|
||||
);
|
||||
|
||||
const invoice = await ClubInvoice.create({
|
||||
clubId,
|
||||
@@ -265,6 +345,8 @@ class ClubInvoiceService {
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await syncInvoiceAccountTransaction(invoice, transaction);
|
||||
|
||||
return ClubInvoice.findOne({
|
||||
where: { id: invoice.id, clubId },
|
||||
include: [
|
||||
@@ -290,8 +372,22 @@ class ClubInvoiceService {
|
||||
const totals = summarizeItems(normalized.items);
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
if (invoice.invoiceNumber && normalized.invoiceDirection !== invoice.invoiceDirection) {
|
||||
const error = new Error('Die Rechnungsrichtung kann nach Vergabe der Rechnungsnummer nicht mehr geändert werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const invoiceNumber = invoice.invoiceNumber || await generateNextInvoiceNumber(
|
||||
clubId,
|
||||
normalized.invoiceDirection,
|
||||
normalized.issuedOn || invoice.issuedOn,
|
||||
transaction
|
||||
);
|
||||
|
||||
await invoice.update({
|
||||
...normalized,
|
||||
invoiceNumber,
|
||||
...totals,
|
||||
archivedAt: normalized.status === 'archived' ? (invoice.archivedAt || new Date()) : null,
|
||||
}, { transaction });
|
||||
@@ -314,6 +410,8 @@ class ClubInvoiceService {
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await syncInvoiceAccountTransaction(invoice, transaction);
|
||||
|
||||
return ClubInvoice.findOne({
|
||||
where: { id: invoice.id, clubId },
|
||||
include: [
|
||||
@@ -340,13 +438,22 @@ class ClubInvoiceService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await invoice.update({
|
||||
status,
|
||||
archivedAt: status === 'archived' ? (invoice.archivedAt || new Date()) : null,
|
||||
paidOn: status === 'paid' ? (invoice.paidOn || new Date().toISOString().slice(0, 10)) : invoice.paidOn,
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await invoice.update({
|
||||
status,
|
||||
archivedAt: status === 'archived' ? (invoice.archivedAt || new Date()) : null,
|
||||
paidOn: status === 'paid' ? (invoice.paidOn || new Date().toISOString().slice(0, 10)) : invoice.paidOn,
|
||||
}, { transaction });
|
||||
await syncInvoiceAccountTransaction(invoice, transaction);
|
||||
});
|
||||
|
||||
return invoice;
|
||||
return invoice.reload({
|
||||
include: [
|
||||
{ model: ClubInvoiceParty, as: 'party', required: false },
|
||||
{ model: ClubInvoiceItem, as: 'items', required: false },
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInvoice(clubId, invoiceId) {
|
||||
@@ -358,6 +465,14 @@ class ClubInvoiceService {
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await ClubAccountTransaction.destroy({
|
||||
where: {
|
||||
clubId,
|
||||
invoiceId,
|
||||
bookingType: 'invoice',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
await ClubInvoiceItem.destroy({ where: { invoiceId }, transaction });
|
||||
await invoice.destroy({ transaction });
|
||||
});
|
||||
|
||||
409
backend/services/clubPaymentClaimService.js
Normal file
409
backend/services/clubPaymentClaimService.js
Normal file
@@ -0,0 +1,409 @@
|
||||
import { Op, Transaction } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import { ClubAccount, ClubAccountTransaction, ClubPaymentClaim, Member } from '../models/index.js';
|
||||
|
||||
const CLAIM_TYPES = new Set(['membership_fee', 'additional_fee', 'course_fee', 'penalty_fee', 'other']);
|
||||
const CLAIM_STATUSES = new Set(['open', 'partially_paid', 'paid', 'written_off', 'cancelled']);
|
||||
|
||||
function trimText(value, maxLength = null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return null;
|
||||
return maxLength ? normalized.slice(0, maxLength) : normalized;
|
||||
}
|
||||
|
||||
function normalizePayload(payload = {}) {
|
||||
return {
|
||||
memberId: Number(payload.memberId) || null,
|
||||
feeRuleId: Number(payload.feeRuleId) || null,
|
||||
claimType: CLAIM_TYPES.has(payload.claimType) ? payload.claimType : 'membership_fee',
|
||||
status: CLAIM_STATUSES.has(payload.status) ? payload.status : 'open',
|
||||
dueOn: trimText(payload.dueOn, 10),
|
||||
amountCents: Number.parseInt(payload.amountCents, 10) || 0,
|
||||
paidAmountCents: Math.max(0, Number.parseInt(payload.paidAmountCents, 10) || 0),
|
||||
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
|
||||
reminderLevel: Math.max(0, Number.parseInt(payload.reminderLevel, 10) || 0),
|
||||
notes: trimText(payload.notes),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReminderTimestamp(existingClaim, nextReminderLevel) {
|
||||
const previousReminderLevel = Math.max(0, Number.parseInt(existingClaim?.reminderLevel, 10) || 0);
|
||||
if (nextReminderLevel <= 0) return null;
|
||||
if (nextReminderLevel > previousReminderLevel) return new Date();
|
||||
return existingClaim?.lastReminderAt || new Date();
|
||||
}
|
||||
|
||||
function clampPaidAmount(amountCents, paidAmountCents) {
|
||||
return Math.max(0, Math.min(Number(amountCents || 0), Number(paidAmountCents || 0)));
|
||||
}
|
||||
|
||||
function deriveFinancialStatus(status, amountCents, paidAmountCents) {
|
||||
if (status === 'cancelled' || status === 'written_off') return status;
|
||||
if (Number(amountCents || 0) <= 0) return 'open';
|
||||
if (paidAmountCents >= amountCents) return 'paid';
|
||||
if (paidAmountCents > 0) return 'partially_paid';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function deriveStatusFromBalance(amountCents, paidAmountCents) {
|
||||
if (Number(amountCents || 0) <= 0) return 'open';
|
||||
if (paidAmountCents >= amountCents) return 'paid';
|
||||
if (paidAmountCents > 0) return 'partially_paid';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function validatePayload(payload) {
|
||||
if (!payload.memberId) {
|
||||
const error = new Error('Mitglied ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!payload.dueOn) {
|
||||
const error = new Error('Fälligkeitsdatum ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!Number.isFinite(Number(payload.amountCents)) || Number(payload.amountCents) <= 0) {
|
||||
const error = new Error('Der Betrag muss größer als 0 sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMemberBelongsToClub(clubId, memberId) {
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId } });
|
||||
if (!member) {
|
||||
const error = new Error('Mitglied wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return member;
|
||||
}
|
||||
|
||||
class ClubPaymentClaimService {
|
||||
async listClaims(clubId) {
|
||||
const claims = await ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
[Op.or]: [
|
||||
{ archivedAt: null },
|
||||
{ archivedAt: { [Op.is]: null } },
|
||||
],
|
||||
status: { [Op.notIn]: ['written_off', 'cancelled'] },
|
||||
},
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
order: [['dueOn', 'ASC'], ['updatedAt', 'DESC']],
|
||||
});
|
||||
|
||||
return { claims };
|
||||
}
|
||||
|
||||
async createClaim(clubId, payload) {
|
||||
const normalized = normalizePayload(payload);
|
||||
validatePayload(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.memberId);
|
||||
const paidAmountCents = clampPaidAmount(normalized.amountCents, normalized.paidAmountCents);
|
||||
const status = deriveFinancialStatus(normalized.status, normalized.amountCents, paidAmountCents);
|
||||
|
||||
const claim = await ClubPaymentClaim.create({
|
||||
clubId,
|
||||
...normalized,
|
||||
paidAmountCents,
|
||||
status,
|
||||
settledAt: status === 'paid' ? new Date() : null,
|
||||
lastPaidAt: paidAmountCents > 0 ? new Date() : null,
|
||||
lastReminderAt: normalized.reminderLevel > 0 ? new Date() : null,
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async updateClaim(clubId, claimId, payload) {
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizePayload(payload);
|
||||
validatePayload(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.memberId);
|
||||
const paidAmountCents = clampPaidAmount(normalized.amountCents, normalized.paidAmountCents);
|
||||
const status = deriveFinancialStatus(normalized.status, normalized.amountCents, paidAmountCents);
|
||||
const previousPaidAmount = Number(claim.paidAmountCents || 0);
|
||||
|
||||
await claim.update({
|
||||
...normalized,
|
||||
paidAmountCents,
|
||||
status,
|
||||
settledAt: status === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: paidAmountCents > previousPaidAmount ? new Date() : (paidAmountCents > 0 ? (claim.lastPaidAt || new Date()) : null),
|
||||
lastReminderAt: resolveReminderTimestamp(claim, normalized.reminderLevel),
|
||||
archivedAt: ['written_off', 'cancelled'].includes(status) ? (claim.archivedAt || new Date()) : null,
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async updateClaimStatus(clubId, claimId, status) {
|
||||
if (!CLAIM_STATUSES.has(status)) {
|
||||
const error = new Error('Ungültiger Zahlungsstatus.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await claim.update({
|
||||
status,
|
||||
paidAmountCents: status === 'paid' ? Number(claim.amountCents || 0) : Number(claim.paidAmountCents || 0),
|
||||
settledAt: status === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: status === 'paid' ? (claim.lastPaidAt || new Date()) : claim.lastPaidAt,
|
||||
archivedAt: ['written_off', 'cancelled'].includes(status) ? (claim.archivedAt || new Date()) : null,
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteClaim(clubId, claimId) {
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await claim.destroy();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async registerPayment(clubId, claimId, payload = {}) {
|
||||
const amountCents = Number.parseInt(payload.amountCents, 10) || 0;
|
||||
if (amountCents <= 0) {
|
||||
const error = new Error('Der Zahlungsbetrag muss größer als 0 sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (['cancelled', 'written_off', 'paid'].includes(claim.status)) {
|
||||
const error = new Error('Für diesen Status kann keine Teilzahlung mehr erfasst werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
const accountId = Number(payload.accountId) || null;
|
||||
let account = null;
|
||||
if (accountId) {
|
||||
account = await ClubAccount.findOne({ where: { id: accountId, clubId } });
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const nextPaidAmount = clampPaidAmount(claim.amountCents, Number(claim.paidAmountCents || 0) + amountCents);
|
||||
const actualPaymentAmountCents = nextPaidAmount - Number(claim.paidAmountCents || 0);
|
||||
if (actualPaymentAmountCents <= 0) {
|
||||
const error = new Error('Die Forderung ist bereits vollständig beglichen.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
const nextStatus = deriveFinancialStatus(claim.status, claim.amountCents, nextPaidAmount);
|
||||
const note = trimText(payload.note);
|
||||
const notePrefix = `Teilzahlung ${formatMoney(actualPaymentAmountCents, claim.currencyCode)} erfasst`;
|
||||
const appendedNotes = notePrefix
|
||||
? [claim.notes, `${notePrefix}${note ? `: ${note}` : ''}`].filter(Boolean).join('\n')
|
||||
: claim.notes;
|
||||
const bookingDate = trimText(payload.bookingDate, 10) || new Date().toISOString().slice(0, 10);
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await this.applyPaymentToClaim(clubId, claim, {
|
||||
amountCents: actualPaymentAmountCents,
|
||||
paidAmountCents: nextPaidAmount,
|
||||
status: nextStatus,
|
||||
notes: appendedNotes,
|
||||
}, transaction);
|
||||
|
||||
if (account) {
|
||||
await ClubAccountTransaction.create({
|
||||
clubId,
|
||||
accountId: account.id,
|
||||
paymentClaimId: claim.id,
|
||||
direction: 'credit',
|
||||
bookingType: 'payment_claim',
|
||||
status: 'booked',
|
||||
bookingDate,
|
||||
valueDate: bookingDate,
|
||||
amountCents: actualPaymentAmountCents,
|
||||
currencyCode: claim.currencyCode || account.currencyCode || 'EUR',
|
||||
reference: payload.reference ? trimText(payload.reference, 255) : `Forderung #${claim.id}`,
|
||||
notes: note || `Zahlung zu Forderung #${claim.id}`,
|
||||
}, { transaction });
|
||||
}
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async reconcileFromTransactions(clubId, claimId, dbTransaction = null) {
|
||||
const claim = await ClubPaymentClaim.findOne({
|
||||
where: { id: claimId, clubId },
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (['cancelled', 'written_off'].includes(claim.status)) {
|
||||
return claim;
|
||||
}
|
||||
|
||||
const transactions = await ClubAccountTransaction.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
paymentClaimId: claim.id,
|
||||
direction: 'credit',
|
||||
status: 'booked',
|
||||
},
|
||||
order: [['bookingDate', 'ASC'], ['createdAt', 'ASC']],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
|
||||
const paidAmountCents = transactions.reduce((sum, transaction) => sum + Number(transaction.amountCents || 0), 0);
|
||||
const latestPaymentTransaction = transactions[transactions.length - 1] || null;
|
||||
const nextStatus = deriveStatusFromBalance(Number(claim.amountCents || 0), paidAmountCents);
|
||||
|
||||
await claim.update({
|
||||
paidAmountCents,
|
||||
status: nextStatus,
|
||||
settledAt: nextStatus === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: paidAmountCents > 0
|
||||
? (latestPaymentTransaction?.bookingDate ? new Date(`${latestPaymentTransaction.bookingDate}T00:00:00`) : (claim.lastPaidAt || new Date()))
|
||||
: null,
|
||||
archivedAt: nextStatus === 'paid' ? null : claim.archivedAt,
|
||||
}, dbTransaction ? { transaction: dbTransaction } : undefined);
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
transaction: dbTransaction || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async applyPaymentToClaim(clubId, claimOrId, payment, dbTransaction = null) {
|
||||
const claimId = typeof claimOrId === 'object' && claimOrId ? claimOrId.id : claimOrId;
|
||||
const claim = typeof claimOrId === 'object' && claimOrId ? claimOrId : await ClubPaymentClaim.findOne({
|
||||
where: { id: claimId, clubId },
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (['cancelled', 'written_off', 'paid'].includes(claim.status)) {
|
||||
const error = new Error('Für diesen Status kann keine Zahlung mehr erfasst werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const incomingAmountCents = Number.parseInt(payment?.amountCents, 10) || 0;
|
||||
if (incomingAmountCents <= 0) {
|
||||
const error = new Error('Der Zahlungsbetrag muss größer als 0 sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const currentPaidAmount = Number(claim.paidAmountCents || 0);
|
||||
const nextPaidAmount = clampPaidAmount(claim.amountCents, payment.paidAmountCents != null
|
||||
? Number(payment.paidAmountCents)
|
||||
: currentPaidAmount + incomingAmountCents);
|
||||
const appliedAmountCents = nextPaidAmount - currentPaidAmount;
|
||||
if (appliedAmountCents <= 0) {
|
||||
const error = new Error('Die Forderung ist bereits vollständig beglichen.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextStatus = payment.status || deriveFinancialStatus(claim.status, claim.amountCents, nextPaidAmount);
|
||||
await claim.update({
|
||||
paidAmountCents: nextPaidAmount,
|
||||
status: nextStatus,
|
||||
settledAt: nextStatus === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: new Date(),
|
||||
notes: payment.notes != null ? payment.notes : claim.notes,
|
||||
lastReminderAt: payment.lastReminderAt != null ? payment.lastReminderAt : claim.lastReminderAt,
|
||||
}, dbTransaction ? { transaction: dbTransaction } : undefined);
|
||||
|
||||
return {
|
||||
claim: await claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
transaction: dbTransaction || undefined,
|
||||
}),
|
||||
appliedAmountCents,
|
||||
nextPaidAmount,
|
||||
nextStatus,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatMoney(amountCents, currencyCode = 'EUR') {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: currencyCode || 'EUR',
|
||||
}).format(Number(amountCents || 0) / 100);
|
||||
}
|
||||
|
||||
export default new ClubPaymentClaimService();
|
||||
@@ -75,6 +75,7 @@ class ClubService {
|
||||
countryCode,
|
||||
stateCode,
|
||||
memberDataQualityRequirements,
|
||||
feeRules,
|
||||
outgoingInvoicePrefix,
|
||||
outgoingInvoiceNextNumber,
|
||||
incomingInvoicePrefix,
|
||||
@@ -93,6 +94,9 @@ class ClubService {
|
||||
if (memberDataQualityRequirements !== undefined) {
|
||||
updates.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(memberDataQualityRequirements);
|
||||
}
|
||||
if (feeRules !== undefined) {
|
||||
updates.feeRules = this.normalizeFeeRules(feeRules);
|
||||
}
|
||||
if (outgoingInvoicePrefix !== undefined) updates.outgoingInvoicePrefix = this.normalizeInvoicePrefix(outgoingInvoicePrefix, 'RE');
|
||||
if (incomingInvoicePrefix !== undefined) updates.incomingInvoicePrefix = this.normalizeInvoicePrefix(incomingInvoicePrefix, 'EI');
|
||||
if (outgoingInvoiceNextNumber !== undefined) updates.outgoingInvoiceNextNumber = this.normalizeInvoiceNextNumber(outgoingInvoiceNextNumber);
|
||||
@@ -131,6 +135,35 @@ class ClubService {
|
||||
);
|
||||
}
|
||||
|
||||
normalizeFeeRules(rules) {
|
||||
if (!Array.isArray(rules)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rules
|
||||
.map((rule, index) => {
|
||||
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const code = String(rule.code || rule.contributionGroupCode || '').trim();
|
||||
const label = String(rule.label || '').trim();
|
||||
const amountCents = Number.parseInt(rule.amountCents ?? Math.round(Number(rule.amountEuro || 0) * 100), 10);
|
||||
const cycle = String(rule.cycle || 'monthly').trim().toLowerCase();
|
||||
const note = String(rule.note || '').trim();
|
||||
|
||||
return {
|
||||
id: String(rule.id || `${Date.now()}-${index}`),
|
||||
code,
|
||||
label,
|
||||
amountCents: Number.isFinite(amountCents) && amountCents >= 0 ? amountCents : 0,
|
||||
cycle: ['monthly', 'quarterly', 'half_yearly', 'yearly', 'one_time'].includes(cycle) ? cycle : 'monthly',
|
||||
note: note || null,
|
||||
};
|
||||
})
|
||||
.filter((rule) => rule && (rule.code || rule.label || rule.amountCents > 0));
|
||||
}
|
||||
|
||||
normalizeInvoicePrefix(prefix, fallback) {
|
||||
const normalized = String(prefix || fallback || '').trim().toUpperCase().slice(0, 24);
|
||||
return normalized || fallback;
|
||||
|
||||
@@ -194,27 +194,29 @@ class ClubStatisticsService {
|
||||
const today = new Date();
|
||||
for (const claim of paymentClaims) {
|
||||
const amount = Number(claim.amountCents || 0);
|
||||
const paidAmount = Math.max(0, Number(claim.paidAmountCents || 0));
|
||||
const remainingAmount = Math.max(0, amount - paidAmount);
|
||||
const dueDate = claim.dueOn ? new Date(claim.dueOn) : null;
|
||||
const monthKey = getMonthKey(claim.dueOn || claim.createdAt);
|
||||
const monthEntry = monthKey ? monthMap.get(monthKey) : null;
|
||||
|
||||
if (claim.status === 'paid') {
|
||||
paymentTotals.paidCount += 1;
|
||||
paymentTotals.paidAmountCents += amount;
|
||||
paymentTotals.paidAmountCents += paidAmount || amount;
|
||||
if (monthEntry) {
|
||||
monthEntry.claimPaidCount += 1;
|
||||
monthEntry.claimPaidAmountCents += amount;
|
||||
monthEntry.claimPaidAmountCents += paidAmount || amount;
|
||||
}
|
||||
} else if (['open', 'partially_paid'].includes(claim.status)) {
|
||||
paymentTotals.openCount += 1;
|
||||
paymentTotals.openAmountCents += amount;
|
||||
paymentTotals.openAmountCents += remainingAmount;
|
||||
if (dueDate && dueDate < today) {
|
||||
paymentTotals.overdueCount += 1;
|
||||
paymentTotals.overdueAmountCents += amount;
|
||||
paymentTotals.overdueAmountCents += remainingAmount;
|
||||
}
|
||||
if (monthEntry) {
|
||||
monthEntry.claimOpenCount += 1;
|
||||
monthEntry.claimOpenAmountCents += amount;
|
||||
monthEntry.claimOpenAmountCents += remainingAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Op } from 'sequelize';
|
||||
import {
|
||||
CalendarEvent,
|
||||
ClubCommunicationRecipient,
|
||||
ClubCommunicationThread,
|
||||
ClubDocument,
|
||||
ClubInvoiceParty,
|
||||
ClubInvoice,
|
||||
ClubPaymentClaim,
|
||||
ClubRequest,
|
||||
ClubSepaMandate,
|
||||
@@ -8,7 +13,7 @@ import {
|
||||
ClubTaskSuppression,
|
||||
Member,
|
||||
} from '../models/index.js';
|
||||
import { CLUB_TASK_DEFINITIONS, getClubTaskDefinitionMap } from './clubTaskDefinitions.js';
|
||||
import { CLUB_TASK_DEFINITIONS, CLUB_WORKFLOW_SOURCES, getClubTaskDefinitionMap } from './clubTaskDefinitions.js';
|
||||
|
||||
const definitionMap = getClubTaskDefinitionMap();
|
||||
|
||||
@@ -43,6 +48,14 @@ function derivePriority(days) {
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function formatReminderStage(reminderLevel) {
|
||||
const level = Math.max(0, Number.parseInt(reminderLevel, 10) || 0);
|
||||
if (level <= 0) return 'keine Mahnung';
|
||||
if (level === 1) return '1. Mahnung';
|
||||
if (level === 2) return '2. Mahnung';
|
||||
return 'letzte Mahnung';
|
||||
}
|
||||
|
||||
function personName(entity) {
|
||||
return [entity?.firstName, entity?.lastName].filter(Boolean).join(' ').trim() || entity?.email || 'Unbekannt';
|
||||
}
|
||||
@@ -111,10 +124,67 @@ function requestSuggestionFor(request, today) {
|
||||
};
|
||||
}
|
||||
|
||||
function documentSuggestionFor(document, today) {
|
||||
const updatedAt = document.updatedAt ? new Date(document.updatedAt) : today;
|
||||
const ageDays = Math.floor((today.getTime() - updatedAt.getTime()) / 86400000);
|
||||
const type = String(document.documentType || '').toLowerCase();
|
||||
const status = String(document.status || '').toLowerCase();
|
||||
|
||||
if (!['satzung', 'protokoll', 'nachweis'].includes(type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'archived' || status === 'obsolete') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reviewThresholdDays = {
|
||||
satzung: 365,
|
||||
protokoll: 90,
|
||||
nachweis: 30,
|
||||
}[type] || 90;
|
||||
|
||||
if (ageDays < reviewThresholdDays && status !== 'draft') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
taskType: 'document_review_required',
|
||||
title: `${type === 'satzung' ? 'Satzung' : type === 'protokoll' ? 'Protokoll' : 'Nachweis'} prüfen: ${document.title}`,
|
||||
description: 'Wichtige Vereinsdokumente vor Freigabe oder Archivierung prüfen und den aktuellen Stand bestätigen.',
|
||||
priority: type === 'satzung' ? 'high' : 'normal',
|
||||
dueAt: addDays(updatedAt, Math.min(14, reviewThresholdDays)),
|
||||
};
|
||||
}
|
||||
|
||||
function sponsorPartySuggestionFor(party, today) {
|
||||
const status = String(party.status || '').toLowerCase();
|
||||
const validTo = party.validTo ? new Date(party.validTo) : null;
|
||||
const daysLeft = validTo ? daysUntil(validTo, today) : null;
|
||||
|
||||
if (status !== 'active' || !validTo || Number.isNaN(validTo.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (daysLeft > 60) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
taskType: 'sponsor_contract_renewal',
|
||||
title: `Sponsoringvertrag von ${party.name} verlängern`,
|
||||
description: daysLeft <= 0
|
||||
? 'Der Sponsorvertrag ist bereits abgelaufen und sollte umgehend geklärt werden.'
|
||||
: `Der Sponsorvertrag läuft in ${daysLeft} Tagen aus und sollte rechtzeitig verlängert werden.`,
|
||||
priority: daysLeft <= 14 ? 'high' : 'normal',
|
||||
dueAt: validTo,
|
||||
};
|
||||
}
|
||||
|
||||
class ClubTaskAutomationService {
|
||||
async buildAutomationOverview(clubId) {
|
||||
const today = todayStart();
|
||||
const [currentTasks, requests, members, mandates, paymentClaims, events, suppressions] = await Promise.all([
|
||||
const [currentTasks, requests, members, mandates, paymentClaims, invoices, parties, documents, communicationRecipients, events, suppressions] = await Promise.all([
|
||||
ClubTask.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
@@ -152,6 +222,41 @@ class ClubTaskAutomationService {
|
||||
},
|
||||
order: [['dueOn', 'ASC']],
|
||||
}),
|
||||
ClubInvoice.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['issued', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
dueOn: { [Op.ne]: null },
|
||||
},
|
||||
order: [['dueOn', 'ASC']],
|
||||
}),
|
||||
ClubInvoiceParty.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
partyType: 'sponsor',
|
||||
},
|
||||
order: [['status', 'ASC'], ['validTo', 'ASC'], ['name', 'ASC']],
|
||||
}),
|
||||
ClubDocument.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
documentType: { [Op.in]: ['satzung', 'protokoll', 'nachweis'] },
|
||||
status: { [Op.in]: ['active', 'draft'] },
|
||||
},
|
||||
order: [['updatedAt', 'DESC']],
|
||||
}),
|
||||
ClubCommunicationRecipient.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
deliveryStatus: 'failed',
|
||||
retryable: true,
|
||||
},
|
||||
include: [
|
||||
{ model: ClubCommunicationThread, as: 'thread', required: false },
|
||||
],
|
||||
order: [['updatedAt', 'DESC']],
|
||||
}),
|
||||
CalendarEvent.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
@@ -217,6 +322,49 @@ class ClubTaskAutomationService {
|
||||
});
|
||||
}
|
||||
|
||||
for (const document of documents) {
|
||||
const suggestion = documentSuggestionFor(document, today);
|
||||
if (!suggestion) continue;
|
||||
const key = buildAutomationKey(suggestion.taskType, 'club_document', document.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
...suggestion,
|
||||
automationSource: 'club_documents',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([document.updatedAt, document.status, document.currentVersionNo, document.title]),
|
||||
sourceEntityType: 'club_document',
|
||||
sourceEntityId: document.id,
|
||||
sourceSnapshot: {
|
||||
documentType: document.documentType,
|
||||
status: document.status,
|
||||
title: document.title,
|
||||
currentVersionNo: document.currentVersionNo,
|
||||
archivedAt: document.archivedAt || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const party of parties) {
|
||||
const suggestion = sponsorPartySuggestionFor(party, today);
|
||||
if (!suggestion) continue;
|
||||
const key = buildAutomationKey(suggestion.taskType, 'club_invoice_party', party.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
...suggestion,
|
||||
automationSource: 'club_invoice_parties',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([party.updatedAt, party.status, party.validTo, party.contractReference]),
|
||||
sourceEntityType: 'club_invoice_party',
|
||||
sourceEntityId: party.id,
|
||||
sourceSnapshot: {
|
||||
partyName: party.name,
|
||||
status: party.status,
|
||||
contractReference: party.contractReference || null,
|
||||
validTo: party.validTo || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
const name = personName(member);
|
||||
if (!String(member.email || '').trim()) {
|
||||
@@ -279,6 +427,7 @@ class ClubTaskAutomationService {
|
||||
|
||||
for (const claim of paymentClaims) {
|
||||
const dueDate = claim.dueOn ? new Date(claim.dueOn) : today;
|
||||
const remainingAmountCents = Math.max(0, Number(claim.amountCents || 0) - Number(claim.paidAmountCents || 0));
|
||||
const claimTaskType = Number(claim.reminderLevel || 0) > 0
|
||||
? 'payment_claim_reminder'
|
||||
: daysUntil(dueDate, today) < 0
|
||||
@@ -290,16 +439,16 @@ class ClubTaskAutomationService {
|
||||
taskType: claimTaskType,
|
||||
title:
|
||||
claimTaskType === 'payment_claim_reminder'
|
||||
? `Mahnfall ${claim.id} prüfen`
|
||||
? `${formatReminderStage(claim.reminderLevel)} für Forderung ${claim.id} prüfen`
|
||||
: claimTaskType === 'payment_claim_overdue'
|
||||
? `Überfällige Zahlung ${claim.id} nachfassen`
|
||||
: `Fällige Zahlung ${claim.id} vorbereiten`,
|
||||
description:
|
||||
claimTaskType === 'payment_claim_reminder'
|
||||
? `Offene Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} mit bestehender Mahnstufe prüfen.`
|
||||
? `Offene Restforderung über ${(remainingAmountCents / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} mit ${formatReminderStage(claim.reminderLevel)} prüfen.`
|
||||
: claimTaskType === 'payment_claim_overdue'
|
||||
? `Überfällige Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} priorisiert nachverfolgen.`
|
||||
: `Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} vor Fälligkeit organisatorisch vorbereiten.`,
|
||||
? `Überfällige Restforderung über ${(remainingAmountCents / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} priorisiert nachverfolgen.`
|
||||
: `Restforderung über ${(remainingAmountCents / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} vor Fälligkeit organisatorisch vorbereiten.`,
|
||||
priority: derivePriority(daysUntil(dueDate, today)),
|
||||
dueAt: dueDate,
|
||||
automationSource: 'club_payment_claims',
|
||||
@@ -309,17 +458,77 @@ class ClubTaskAutomationService {
|
||||
sourceEntityId: claim.id,
|
||||
sourceSnapshot: {
|
||||
amountCents: Number(claim.amountCents),
|
||||
paidAmountCents: Number(claim.paidAmountCents || 0),
|
||||
remainingAmountCents,
|
||||
currencyCode: claim.currencyCode,
|
||||
dueOn: claim.dueOn,
|
||||
status: claim.status,
|
||||
reminderLevel: claim.reminderLevel,
|
||||
reminderStage: formatReminderStage(claim.reminderLevel),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const dueDate = invoice.dueOn ? new Date(invoice.dueOn) : today;
|
||||
const invoiceTaskType = invoice.invoiceDirection === 'incoming'
|
||||
? (daysUntil(dueDate, today) < 0 ? 'invoice_incoming_overdue' : 'invoice_incoming_due_soon')
|
||||
: (daysUntil(dueDate, today) < 0 ? 'invoice_outgoing_overdue' : 'invoice_outgoing_due_soon');
|
||||
const key = buildAutomationKey(invoiceTaskType, 'club_invoice', invoice.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
taskType: invoiceTaskType,
|
||||
title: `${invoice.invoiceDirection === 'incoming' ? 'Eingangsrechnung' : 'Ausgangsrechnung'} ${invoice.invoiceNumber || `#${invoice.id}`} prüfen`,
|
||||
description: invoice.invoiceDirection === 'incoming'
|
||||
? 'Offene Eingangsrechnung rechtzeitig für Zahlung oder Klärung vorbereiten.'
|
||||
: 'Offene Ausgangsrechnung auf Zahlungseingang und Erinnerung prüfen.',
|
||||
priority: derivePriority(daysUntil(dueDate, today)),
|
||||
dueAt: dueDate,
|
||||
automationSource: 'club_invoices',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([invoice.updatedAt, invoice.status, invoice.dueOn, invoice.grossAmountCents]),
|
||||
sourceEntityType: 'club_invoice',
|
||||
sourceEntityId: invoice.id,
|
||||
sourceSnapshot: {
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoiceDirection: invoice.invoiceDirection,
|
||||
amountCents: Number(invoice.grossAmountCents || 0),
|
||||
currencyCode: invoice.currencyCode || 'EUR',
|
||||
dueOn: invoice.dueOn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of communicationRecipients) {
|
||||
const key = buildAutomationKey('communication_delivery_retry', 'club_communication_recipient', recipient.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
taskType: 'communication_delivery_retry',
|
||||
title: `Versandfehler für ${recipient.recipientName || 'Empfänger'} prüfen`,
|
||||
description: recipient.errorMessage || 'Retry-fähiger Versandfehler in der Vereinskommunikation.',
|
||||
priority: 'high',
|
||||
dueAt: addDays(today, 1),
|
||||
automationSource: 'club_communication',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([recipient.updatedAt, recipient.errorCode, recipient.errorMessage]),
|
||||
sourceEntityType: 'club_communication_recipient',
|
||||
sourceEntityId: recipient.id,
|
||||
sourceSnapshot: {
|
||||
recipientName: recipient.recipientName,
|
||||
threadId: recipient.threadId,
|
||||
threadSubject: recipient.thread?.subject || null,
|
||||
errorMessage: recipient.errorMessage || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (['cancelled', 'done'].includes(String(event.status || ''))) {
|
||||
continue;
|
||||
}
|
||||
if (hasExistingSourceTask(currentTasks, 'calendar_event', event.id, 'calendar_events')) continue;
|
||||
const dueDate = event.startDate ? addDays(new Date(event.startDate), -3) : addDays(today, 7);
|
||||
const referenceDate = event.registrationDeadline || event.startDate;
|
||||
const dueDate = referenceDate ? addDays(new Date(referenceDate), -3) : addDays(today, 7);
|
||||
const eventTaskType = daysUntil(dueDate, today) <= 1 ? 'calendar_event_deadline_check' : 'calendar_event_prepare';
|
||||
const key = buildAutomationKey(eventTaskType, 'calendar_event', event.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
@@ -337,13 +546,17 @@ class ClubTaskAutomationService {
|
||||
dueAt: dueDate,
|
||||
automationSource: 'calendar_events',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([event.updatedAt, event.startDate, event.endDate, event.category]),
|
||||
suppressionToken: suggestionToken([event.updatedAt, event.startDate, event.endDate, event.registrationDeadline, event.status, event.eventType]),
|
||||
sourceEntityType: 'calendar_event',
|
||||
sourceEntityId: event.id,
|
||||
sourceSnapshot: {
|
||||
title: event.title,
|
||||
eventType: event.eventType || null,
|
||||
status: event.status || null,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
registrationDeadline: event.registrationDeadline || null,
|
||||
location: event.location || null,
|
||||
category: event.category || null,
|
||||
},
|
||||
});
|
||||
@@ -357,6 +570,7 @@ class ClubTaskAutomationService {
|
||||
|
||||
return {
|
||||
definitions: CLUB_TASK_DEFINITIONS,
|
||||
workflowSources: CLUB_WORKFLOW_SOURCES,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
@@ -477,6 +691,32 @@ class ClubTaskAutomationService {
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
case 'sponsoring_prepare_offer':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `Sponsoringangebot für ${person} vorbereiten`,
|
||||
description: 'Konkretes Sponsoringangebot auf Basis des Erstkontakts zusammenstellen.',
|
||||
priority: 'normal',
|
||||
dueAt: addDays(sourceDate, 2),
|
||||
automationSource: task.automationSource,
|
||||
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
|
||||
sourceEntityType: task.relatedEntityType,
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
case 'sponsoring_follow_up':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `Sponsoringnachfassen bei ${person}`,
|
||||
description: 'Nach Versand des Angebots Rückmeldung einholen und den nächsten Schritt abstimmen.',
|
||||
priority: 'normal',
|
||||
dueAt: addDays(sourceDate, 4),
|
||||
automationSource: task.automationSource,
|
||||
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
|
||||
sourceEntityType: task.relatedEntityType,
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,50 @@ export const CLUB_TASK_DEFINITIONS = [
|
||||
trigger: 'Sponsoringanfrage ist offen oder wartet auf Vereinsreaktion.',
|
||||
description: 'Erstkontakt zu Sponsoringanfragen strukturieren und den nächsten Gesprächstermin vorbereiten.',
|
||||
suggestedAction: 'Ansprechpartner festlegen und Antwort mit weiterem Vorgehen senden.',
|
||||
nextTaskTypes: ['sponsoring_prepare_offer'],
|
||||
},
|
||||
{
|
||||
key: 'sponsoring_prepare_offer',
|
||||
label: 'Sponsoringangebot vorbereiten',
|
||||
source: 'club_requests',
|
||||
category: 'Sponsoring',
|
||||
workflow: 'Sponsoring',
|
||||
trigger: 'Erstkontakt ist erfolgt und ein konkretes Angebot soll vorbereitet werden.',
|
||||
description: 'Sponsoringpaket, Leistungen und Konditionen für den nächsten Kontakt bündeln.',
|
||||
suggestedAction: 'Leistungen abstimmen und Angebot oder Mustervertrag vorbereiten.',
|
||||
nextTaskTypes: ['sponsoring_follow_up'],
|
||||
},
|
||||
{
|
||||
key: 'sponsoring_follow_up',
|
||||
label: 'Nach Sponsoringangebot nachfassen',
|
||||
source: 'club_requests',
|
||||
category: 'Sponsoring',
|
||||
workflow: 'Sponsoring',
|
||||
trigger: 'Sponsoringangebot wurde versendet und eine Rückmeldung steht noch aus.',
|
||||
description: 'Nachfassen, Rückfragen klären und bei Zusage den Übergang in die Rechnungsanlage vorbereiten.',
|
||||
suggestedAction: 'Rückmeldung einholen und Rechnungs- oder Vertragsstart anstoßen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'document_review_required',
|
||||
label: 'Dokument prüfen und freigeben',
|
||||
source: 'club_documents',
|
||||
category: 'Dokumente',
|
||||
workflow: 'Dokumentenpflege',
|
||||
trigger: 'Satzung, Protokoll oder Nachweis wartet auf Prüfung oder Aktualisierung.',
|
||||
description: 'Wichtige Vereinsdokumente nach Änderungen oder in regelmäßigen Abständen prüfen.',
|
||||
suggestedAction: 'Inhalt prüfen, Freigabe dokumentieren und Version sauber archivieren.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'sponsor_contract_renewal',
|
||||
label: 'Sponsoringvertrag verlängern',
|
||||
source: 'club_invoice_parties',
|
||||
category: 'Sponsoring',
|
||||
workflow: 'Sponsoring',
|
||||
trigger: 'Ein Sponsoringvertrag läuft bald aus.',
|
||||
description: 'Laufenden Sponsorvertrag rechtzeitig prüfen und die Verlängerung oder Nachverhandlung vorbereiten.',
|
||||
suggestedAction: 'Kontakt aufnehmen, Laufzeit abstimmen und Vertragsverlängerung vorbereiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
@@ -164,6 +208,61 @@ export const CLUB_TASK_DEFINITIONS = [
|
||||
suggestedAction: 'Mahnung, Rücksprache oder Teilzahlungsentscheidung vorbereiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'invoice_outgoing_due_soon',
|
||||
label: 'Ausgangsrechnung nachverfolgen',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Ausgangsrechnung ist gestellt und bald fällig.',
|
||||
description: 'Offene Ausgangsrechnung kurz vor Fälligkeit aktiv beobachten und Kontakt vorbereiten.',
|
||||
suggestedAction: 'Zahlungseingang prüfen oder Erinnerung vorbereiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'invoice_outgoing_overdue',
|
||||
label: 'Überfällige Ausgangsrechnung nachfassen',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Ausgangsrechnung ist überfällig und noch nicht bezahlt.',
|
||||
description: 'Überfällige Forderung im Vereinskontext verfolgen und weitere Schritte einleiten.',
|
||||
suggestedAction: 'Erinnerung senden, Sponsorenkontakt aufnehmen oder Klärung einleiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'invoice_incoming_due_soon',
|
||||
label: 'Eingangsrechnung einplanen',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Eingangsrechnung ist bald fällig.',
|
||||
description: 'Offene Eingangsrechnung rechtzeitig für Zahlung und Freigabe vorbereiten.',
|
||||
suggestedAction: 'Konto prüfen, Freigabe sichern und Zahlung einplanen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'invoice_incoming_overdue',
|
||||
label: 'Überfällige Eingangsrechnung klären',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Eingangsrechnung ist überfällig.',
|
||||
description: 'Überfällige Eingangsrechnung auf offenen Zahlungsbedarf oder Klärung prüfen.',
|
||||
suggestedAction: 'Zahlung veranlassen oder Lieferantenkontakt aufnehmen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'communication_delivery_retry',
|
||||
label: 'Versandfehler nachfassen',
|
||||
source: 'club_communication',
|
||||
category: 'Kommunikation',
|
||||
workflow: 'Versand',
|
||||
trigger: 'Kommunikationsvorgang hat retry-fähige Zustellfehler.',
|
||||
description: 'Fehlgeschlagene Zustellung prüfen, Empfängerdaten korrigieren oder erneuten Versand auslösen.',
|
||||
suggestedAction: 'Fehler prüfen, E-Mail korrigieren und erneut senden.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'calendar_event_prepare',
|
||||
label: 'Termin vorbereiten',
|
||||
@@ -188,6 +287,63 @@ export const CLUB_TASK_DEFINITIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const CLUB_WORKFLOW_SOURCES = [
|
||||
{
|
||||
key: 'club_requests',
|
||||
label: 'Anfragen',
|
||||
description: 'Kontakt-, Probe- und Mitgliedsanfragen erzeugen Aufgaben entlang des Aufnahme-Workflows.',
|
||||
examples: ['Kontaktanfrage beantworten', 'Probetraining organisieren', 'Mitgliedsanfrage prüfen'],
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
label: 'Mitgliederdaten',
|
||||
description: 'Fehlende Stammdaten wie E-Mail oder Geburtsdatum werden als Datenqualitäts-Aufgaben erkannt.',
|
||||
examples: ['E-Mail ergänzen', 'Geburtsdatum ergänzen'],
|
||||
},
|
||||
{
|
||||
key: 'club_sepa_mandates',
|
||||
label: 'SEPA-Mandate',
|
||||
description: 'Fehlende oder notwendige SEPA-Mandate werden als Finanz- und Onboarding-Aufgaben erzeugt.',
|
||||
examples: ['SEPA-Mandat einholen'],
|
||||
},
|
||||
{
|
||||
key: 'club_payment_claims',
|
||||
label: 'Forderungen',
|
||||
description: 'Offene, fällige und gemahnte Beitragsforderungen erzeugen Nachfass- und Mahnaufgaben.',
|
||||
examples: ['Fällige Zahlung vorbereiten', 'Mahnstufe prüfen'],
|
||||
},
|
||||
{
|
||||
key: 'club_invoices',
|
||||
label: 'Rechnungen',
|
||||
description: 'Offene Ausgangs- und Eingangsrechnungen erzeugen finanzielle Wiedervorlagen entlang der Fälligkeit.',
|
||||
examples: ['Ausgangsrechnung nachverfolgen', 'Eingangsrechnung einplanen'],
|
||||
},
|
||||
{
|
||||
key: 'club_documents',
|
||||
label: 'Dokumente',
|
||||
description: 'Satzungen, Protokolle und Nachweise erzeugen Prüf- und Freigabeaufgaben.',
|
||||
examples: ['Dokument prüfen und freigeben'],
|
||||
},
|
||||
{
|
||||
key: 'club_invoice_parties',
|
||||
label: 'Sponsoren',
|
||||
description: 'Laufende Sponsorenbeziehungen erzeugen Vertragsverlängerungen und Nachfassaufgaben.',
|
||||
examples: ['Sponsoringvertrag verlängern', 'Sponsoringangebot vorbereiten'],
|
||||
},
|
||||
{
|
||||
key: 'club_communication',
|
||||
label: 'Kommunikation',
|
||||
description: 'Retry-fähige Versandfehler werden als Kommunikationsaufgaben sichtbar gemacht.',
|
||||
examples: ['Versandfehler nachfassen'],
|
||||
},
|
||||
{
|
||||
key: 'calendar_events',
|
||||
label: 'Termine',
|
||||
description: 'Bevorstehende Termine und Fristen erzeugen organisatorische Vorbereitungsaufgaben.',
|
||||
examples: ['Termin vorbereiten', 'Terminfrist prüfen'],
|
||||
},
|
||||
];
|
||||
|
||||
export function getClubTaskDefinitionMap() {
|
||||
return CLUB_TASK_DEFINITIONS.reduce((accumulator, definition) => {
|
||||
accumulator[definition.key] = definition;
|
||||
|
||||
@@ -20,6 +20,10 @@ function completedRequestStateForTaskType(taskType) {
|
||||
return { status: 'converted', workflowStage: 'onboarding_completed', closedAt: new Date() };
|
||||
case 'request_sponsoring_reply':
|
||||
return { status: 'waiting', workflowStage: 'sponsoring_contacted' };
|
||||
case 'sponsoring_prepare_offer':
|
||||
return { status: 'waiting', workflowStage: 'sponsoring_offer_prepared' };
|
||||
case 'sponsoring_follow_up':
|
||||
return { status: 'waiting', workflowStage: 'sponsoring_followed_up' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'Gmail',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
let transporter = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (!process.env.EMAIL_USER || !process.env.EMAIL_PASS) {
|
||||
const error = new Error('E-Mail-Versand ist nicht konfiguriert.');
|
||||
error.code = 'EMAIL_CONFIG_MISSING';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
service: 'Gmail',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return transporter;
|
||||
}
|
||||
|
||||
function getDefaultFrom() {
|
||||
return process.env.EMAIL_FROM || process.env.EMAIL_USER;
|
||||
}
|
||||
|
||||
async function sendMail(mailOptions) {
|
||||
return getTransporter().sendMail({
|
||||
from: getDefaultFrom(),
|
||||
...mailOptions,
|
||||
});
|
||||
}
|
||||
|
||||
const sendActivationEmail = async (email, activationCode) => {
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Account Activation',
|
||||
text: `Activate your account by clicking the following link: ${process.env.BASE_URL}/activate/${activationCode}`,
|
||||
};
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
const sendPasswordResetEmail = async (email, resetToken) => {
|
||||
const resetLink = `${process.env.BASE_URL}/reset-password/${resetToken}`;
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Passwort zurücksetzen',
|
||||
html: `
|
||||
@@ -46,8 +68,7 @@ const sendPasswordResetEmail = async (email, resetToken) => {
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
const sendFriendlyMatchInvitationEmail = async ({
|
||||
@@ -68,8 +89,7 @@ const sendFriendlyMatchInvitationEmail = async ({
|
||||
? `<p style="margin-top: 12px;"><strong>Nachricht:</strong><br>${String(message).replace(/</g, '<').replace(/>/g, '>')}</p>`
|
||||
: '';
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: recipientList.join(','),
|
||||
subject: `Freundschaftsspiel-Einladung: ${fromClubName} -> ${toClubName}`,
|
||||
html: `
|
||||
@@ -90,9 +110,7 @@ const sendFriendlyMatchInvitationEmail = async ({
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value ?? '')
|
||||
@@ -110,8 +128,7 @@ const sendMobileFeedbackEmail = async ({
|
||||
backendBaseUrl,
|
||||
user,
|
||||
}) => {
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: 'tsschulz2001@gmail.com',
|
||||
subject: `Android Feedback${screen ? ` - ${screen}` : ''}`,
|
||||
html: `
|
||||
@@ -129,9 +146,23 @@ const sendMobileFeedbackEmail = async ({
|
||||
<div style="white-space:pre-wrap;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;">${escapeHtml(message)}</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail };
|
||||
const sendClubCommunicationEmail = async ({
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
replyTo,
|
||||
}) => {
|
||||
return sendMail({
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
replyTo: replyTo || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail, sendClubCommunicationEmail };
|
||||
|
||||
@@ -292,7 +292,8 @@ class MemberService {
|
||||
}
|
||||
|
||||
async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate, phone, email, active = true, testMembership = false,
|
||||
picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false, adultReleaseApproved = false, adultReserveApproved = false, contacts = []) {
|
||||
picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false, adultReleaseApproved = false, adultReserveApproved = false,
|
||||
contributionGroupCode = null, contacts = []) {
|
||||
try {
|
||||
await checkAccess(userToken, clubId);
|
||||
let member = null;
|
||||
@@ -300,6 +301,7 @@ class MemberService {
|
||||
member = await Member.findOne({ where: { id: memberId } });
|
||||
}
|
||||
const MemberContact = (await import('../models/MemberContact.js')).default;
|
||||
const normalizedContributionGroupCode = String(contributionGroupCode || '').trim() || null;
|
||||
if (member) {
|
||||
member.firstName = firstName;
|
||||
member.lastName = lastName;
|
||||
@@ -318,6 +320,7 @@ class MemberService {
|
||||
member.memberFormHandedOver = !!memberFormHandedOver;
|
||||
member.adultReleaseApproved = !!adultReleaseApproved;
|
||||
member.adultReserveApproved = !!adultReserveApproved;
|
||||
member.contributionGroupCode = normalizedContributionGroupCode;
|
||||
await member.save();
|
||||
|
||||
// Update contacts if provided
|
||||
@@ -363,6 +366,7 @@ class MemberService {
|
||||
memberFormHandedOver: !!memberFormHandedOver,
|
||||
adultReleaseApproved: !!adultReleaseApproved,
|
||||
adultReserveApproved: !!adultReserveApproved,
|
||||
contributionGroupCode: normalizedContributionGroupCode,
|
||||
});
|
||||
|
||||
// Create contacts if provided
|
||||
|
||||
@@ -7,65 +7,120 @@ const ROLE_PERMISSIONS = {
|
||||
admin: {
|
||||
diary: { read: true, write: true, delete: true },
|
||||
members: { read: true, write: true, delete: true },
|
||||
requests: { read: true, write: true, delete: true },
|
||||
tasks: { read: true, write: true, delete: true },
|
||||
teams: { read: true, write: true, delete: true },
|
||||
schedule: { read: true, write: true, delete: true },
|
||||
tournaments: { read: true, write: true, delete: true },
|
||||
statistics: { read: true, write: true },
|
||||
finance_accounts: { read: true, write: true, delete: true },
|
||||
finance_invoices: { read: true, write: true, delete: true },
|
||||
history: { read: true, write: true },
|
||||
archive: { read: true, write: true },
|
||||
settings: { read: true, write: true },
|
||||
permissions: { read: true, write: true },
|
||||
approvals: { read: true, write: true },
|
||||
communication: { read: true, write: true, delete: true },
|
||||
mytischtennis_admin: { read: true, write: true },
|
||||
predefined_activities: { read: true, write: true, delete: true },
|
||||
},
|
||||
trainer: {
|
||||
diary: { read: true, write: true, delete: true },
|
||||
members: { read: true, write: true, delete: false },
|
||||
requests: { read: true, write: true, delete: false },
|
||||
tasks: { read: true, write: true, delete: false },
|
||||
teams: { read: true, write: true, delete: false },
|
||||
schedule: { read: true, write: false, delete: false },
|
||||
tournaments: { read: true, write: true, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: true, delete: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: true, write: true, delete: true },
|
||||
},
|
||||
team_manager: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: true, write: false, delete: false },
|
||||
requests: { read: true, write: false, delete: false },
|
||||
tasks: { read: true, write: true, delete: false },
|
||||
teams: { read: true, write: true, delete: false },
|
||||
schedule: { read: true, write: true, delete: false },
|
||||
tournaments: { read: true, write: false, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: false, delete: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
tournament_manager: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: true, write: false, delete: false },
|
||||
requests: { read: true, write: false, delete: false },
|
||||
tasks: { read: true, write: false, delete: false },
|
||||
teams: { read: false, write: false, delete: false },
|
||||
schedule: { read: false, write: false, delete: false },
|
||||
tournaments: { read: true, write: true, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: false, delete: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
cashier: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: true, write: false, delete: false },
|
||||
requests: { read: true, write: false, delete: false },
|
||||
tasks: { read: true, write: true, delete: false },
|
||||
teams: { read: false, write: false, delete: false },
|
||||
schedule: { read: false, write: false, delete: false },
|
||||
tournaments: { read: false, write: false, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: true, write: true, delete: false },
|
||||
finance_invoices: { read: true, write: true, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: true, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: false, delete: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
member: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: false, write: false, delete: false },
|
||||
requests: { read: false, write: false, delete: false },
|
||||
tasks: { read: false, write: false, delete: false },
|
||||
teams: { read: false, write: false, delete: false },
|
||||
schedule: { read: false, write: false, delete: false },
|
||||
tournaments: { read: false, write: false, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: false, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: false, write: false, delete: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
@@ -76,6 +131,7 @@ const DEFAULT_ROLE_TEMPLATES = [
|
||||
{ roleKey: 'trainer', name: 'Trainer', description: 'Kann Trainingseinheiten, Mitglieder und Teams verwalten', permissions: ROLE_PERMISSIONS.trainer, sortOrder: 20 },
|
||||
{ roleKey: 'team_manager', name: 'Mannschaftsführer', description: 'Kann Teams und Spielpläne verwalten', permissions: ROLE_PERMISSIONS.team_manager, sortOrder: 30 },
|
||||
{ roleKey: 'tournament_manager', name: 'Turnierleiter', description: 'Kann Turniere verwalten', permissions: ROLE_PERMISSIONS.tournament_manager, sortOrder: 40 },
|
||||
{ roleKey: 'cashier', name: 'Kassierer', description: 'Kann Konten, Rechnungen und finanznahe Aufgaben verwalten', permissions: ROLE_PERMISSIONS.cashier, sortOrder: 45 },
|
||||
{ roleKey: 'member', name: 'Mitglied', description: 'Kann nur freigegebene Vereinsbereiche ansehen', permissions: ROLE_PERMISSIONS.member, sortOrder: 50 },
|
||||
];
|
||||
|
||||
@@ -123,13 +179,20 @@ class PermissionService {
|
||||
return {
|
||||
diary: { label: 'Trainingstagebuch', actions: ['read', 'write', 'delete'] },
|
||||
members: { label: 'Mitglieder', actions: ['read', 'write', 'delete'] },
|
||||
requests: { label: 'Anfragen', actions: ['read', 'write', 'delete'] },
|
||||
tasks: { label: 'Aufgaben', actions: ['read', 'write', 'delete'] },
|
||||
teams: { label: 'Teams', actions: ['read', 'write', 'delete'] },
|
||||
schedule: { label: 'Spielpläne', actions: ['read', 'write', 'delete'] },
|
||||
tournaments: { label: 'Turniere', actions: ['read', 'write', 'delete'] },
|
||||
statistics: { label: 'Statistiken', actions: ['read', 'write'] },
|
||||
finance_accounts: { label: 'Konten', actions: ['read', 'write', 'delete'] },
|
||||
finance_invoices: { label: 'Rechnungen', actions: ['read', 'write', 'delete'] },
|
||||
history: { label: 'Historie', actions: ['read', 'write'] },
|
||||
archive: { label: 'Archiv', actions: ['read', 'write'] },
|
||||
settings: { label: 'Einstellungen', actions: ['read', 'write'] },
|
||||
permissions: { label: 'Berechtigungsverwaltung', actions: ['read', 'write'] },
|
||||
approvals: { label: 'Freigaben (Mitgliedsanträge)', actions: ['read', 'write'] },
|
||||
communication: { label: 'Kommunikation', actions: ['read', 'write', 'delete'] },
|
||||
mytischtennis_admin: { label: 'MyTischtennis Admin', actions: ['read', 'write'] },
|
||||
predefined_activities: { label: 'Vordefinierte Aktivitäten', actions: ['read', 'write', 'delete'] },
|
||||
};
|
||||
@@ -152,7 +215,7 @@ class PermissionService {
|
||||
async ensureDefaultRoles(clubId) {
|
||||
const createdRoles = [];
|
||||
for (const template of DEFAULT_ROLE_TEMPLATES) {
|
||||
const [role] = await ClubRole.findOrCreate({
|
||||
const [role, created] = await ClubRole.findOrCreate({
|
||||
where: { clubId, roleKey: template.roleKey },
|
||||
defaults: {
|
||||
clubId,
|
||||
@@ -164,6 +227,16 @@ class PermissionService {
|
||||
sortOrder: template.sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
if (!created && role.isSystemRole) {
|
||||
const mergedPermissions = this.mergePermissions(template.permissions, role.permissions);
|
||||
await role.update({
|
||||
name: role.name || template.name,
|
||||
description: role.description || template.description,
|
||||
permissions: mergedPermissions,
|
||||
sortOrder: role.sortOrder || template.sortOrder,
|
||||
});
|
||||
}
|
||||
createdRoles.push(role);
|
||||
}
|
||||
return createdRoles;
|
||||
|
||||
Reference in New Issue
Block a user