From 90e6c2f9f66c3de185fb0766decf7192cbd77d7c Mon Sep 17 00:00:00 2001
From: "Torsten Schulz (local)"
Date: Tue, 23 Jun 2026 16:17:07 +0200
Subject: [PATCH] Updates, overview extended, club view implemented
---
.../controllers/calendarEventController.js | 15 +-
backend/controllers/clubAccountController.js | 37 +-
.../clubCommunicationController.js | 132 +
.../controllers/clubDashboardController.js | 124 +-
backend/controllers/clubDocumentController.js | 111 +
.../controllers/clubPaymentClaimController.js | 73 +
backend/controllers/clubTaskController.js | 1 +
backend/controllers/clubsController.js | 2 +
backend/controllers/memberController.js | 5 +-
.../create_calendar_events_table.sql | 8 +
backend/models/CalendarEvent.js | 30 +-
backend/models/Club.js | 6 +
backend/models/ClubAccountTransaction.js | 87 +
.../models/ClubCommunicationDeliveryLog.js | 73 +
backend/models/ClubCommunicationMessage.js | 47 +
backend/models/ClubCommunicationRecipient.js | 84 +
backend/models/ClubCommunicationTemplate.js | 58 +
backend/models/ClubCommunicationThread.js | 67 +
backend/models/ClubDistributionGroup.js | 47 +
backend/models/ClubDistributionGroupMember.js | 33 +
backend/models/ClubDocument.js | 61 +
backend/models/ClubDocumentLink.js | 35 +
backend/models/ClubDocumentVersion.js | 65 +
backend/models/ClubInvoiceParty.js | 26 +
backend/models/ClubPaymentClaim.js | 11 +
backend/models/Member.js | 54 +
backend/models/index.js | 82 +
backend/routes/calendarEventRoutes.js | 2 +
backend/routes/clubAccountRoutes.js | 13 +-
backend/routes/clubArchiveRoutes.js | 2 +-
backend/routes/clubCommunicationRoutes.js | 24 +
backend/routes/clubDocumentRoutes.js | 19 +
backend/routes/clubInvoiceRoutes.js | 16 +-
backend/routes/clubPaymentClaimRoutes.js | 17 +
backend/routes/clubRequestRoutes.js | 10 +-
backend/routes/clubTaskRoutes.js | 14 +-
backend/server.js | 16 +-
backend/services/calendarEventService.js | 58 +-
backend/services/clubAccountService.js | 363 +-
backend/services/clubCommunicationService.js | 828 +++++
backend/services/clubDocumentService.js | 361 ++
backend/services/clubInvoiceService.js | 139 +-
backend/services/clubPaymentClaimService.js | 409 +++
backend/services/clubService.js | 33 +
backend/services/clubStatisticsService.js | 12 +-
backend/services/clubTaskAutomationService.js | 256 +-
backend/services/clubTaskDefinitions.js | 156 +
backend/services/clubWorkflowSourceService.js | 4 +
backend/services/emailService.js | 83 +-
backend/services/memberService.js | 6 +-
backend/services/permissionService.js | 75 +-
docs/TODO.md | 62 +-
docs/club-communication-workflow.md | 34 +
docs/club-task-workflow-sources.md | 31 +
frontend/package-lock.json | 184 +-
frontend/package.json | 2 +
frontend/sql/tt-verein-v1-schema.mysql.sql | 198 +-
frontend/sql/tt-verein-v1-schema.sql | 198 ++
.../src/components/DiaryParticipantsPanel.vue | 21 +
frontend/src/components/RichTextEditor.vue | 163 +
.../components/diary/DiaryOverviewPanels.vue | 21 +-
frontend/src/config/clubDataModels.js | 31 +-
frontend/src/config/clubWorkspace.js | 28 +-
frontend/src/i18n/locales/de-CH.json | 3 +
frontend/src/i18n/locales/de-extended.json | 3 +
frontend/src/i18n/locales/de.json | 3 +
frontend/src/i18n/locales/zh.json | 3 +
frontend/src/router.js | 28 +-
frontend/src/utils/reportExport.js | 117 +
frontend/src/utils/richTextDocumentExport.js | 429 +++
frontend/src/views/CalendarView.vue | 9 +-
frontend/src/views/ClubAccountsView.vue | 347 +-
frontend/src/views/ClubCommunicationView.vue | 1510 +++++++++
frontend/src/views/ClubInvoicesView.vue | 141 +-
.../src/views/ClubOperationsWorkspaceView.vue | 2950 +++++++++++++++++
frontend/src/views/ClubSettings.vue | 102 +
frontend/src/views/ClubTasksView.vue | 88 +-
frontend/src/views/DiaryView.vue | 18 +
frontend/src/views/Home.vue | 109 +
frontend/src/views/MembersView.vue | 275 ++
frontend/src/views/PermissionsView.vue | 13 +-
.../de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt | 16 +
82 files changed, 11226 insertions(+), 201 deletions(-)
create mode 100644 backend/controllers/clubCommunicationController.js
create mode 100644 backend/controllers/clubDocumentController.js
create mode 100644 backend/controllers/clubPaymentClaimController.js
create mode 100644 backend/models/ClubAccountTransaction.js
create mode 100644 backend/models/ClubCommunicationDeliveryLog.js
create mode 100644 backend/models/ClubCommunicationMessage.js
create mode 100644 backend/models/ClubCommunicationRecipient.js
create mode 100644 backend/models/ClubCommunicationTemplate.js
create mode 100644 backend/models/ClubCommunicationThread.js
create mode 100644 backend/models/ClubDistributionGroup.js
create mode 100644 backend/models/ClubDistributionGroupMember.js
create mode 100644 backend/models/ClubDocument.js
create mode 100644 backend/models/ClubDocumentLink.js
create mode 100644 backend/models/ClubDocumentVersion.js
create mode 100644 backend/routes/clubCommunicationRoutes.js
create mode 100644 backend/routes/clubDocumentRoutes.js
create mode 100644 backend/routes/clubPaymentClaimRoutes.js
create mode 100644 backend/services/clubCommunicationService.js
create mode 100644 backend/services/clubDocumentService.js
create mode 100644 backend/services/clubPaymentClaimService.js
create mode 100644 docs/club-communication-workflow.md
create mode 100644 docs/club-task-workflow-sources.md
create mode 100644 frontend/src/components/RichTextEditor.vue
create mode 100644 frontend/src/utils/reportExport.js
create mode 100644 frontend/src/utils/richTextDocumentExport.js
create mode 100644 frontend/src/views/ClubCommunicationView.vue
create mode 100644 frontend/src/views/ClubOperationsWorkspaceView.vue
diff --git a/backend/controllers/calendarEventController.js b/backend/controllers/calendarEventController.js
index 4133c97c..4a5a9edb 100644
--- a/backend/controllers/calendarEventController.js
+++ b/backend/controllers/calendarEventController.js
@@ -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;
diff --git a/backend/controllers/clubAccountController.js b/backend/controllers/clubAccountController.js
index 105fcdfb..a9605f3c 100644
--- a/backend/controllers/clubAccountController.js
+++ b/backend/controllers/clubAccountController.js
@@ -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();
diff --git a/backend/controllers/clubCommunicationController.js b/backend/controllers/clubCommunicationController.js
new file mode 100644
index 00000000..0b3b82c7
--- /dev/null
+++ b/backend/controllers/clubCommunicationController.js
@@ -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;
diff --git a/backend/controllers/clubDashboardController.js b/backend/controllers/clubDashboardController.js
index db5673e2..af73149f 100644
--- a/backend/controllers/clubDashboardController.js
+++ b/backend/controllers/clubDashboardController.js
@@ -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}`);
}),
},
diff --git a/backend/controllers/clubDocumentController.js b/backend/controllers/clubDocumentController.js
new file mode 100644
index 00000000..4dfc2b60
--- /dev/null
+++ b/backend/controllers/clubDocumentController.js
@@ -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,
+};
diff --git a/backend/controllers/clubPaymentClaimController.js b/backend/controllers/clubPaymentClaimController.js
new file mode 100644
index 00000000..0af1d571
--- /dev/null
+++ b/backend/controllers/clubPaymentClaimController.js
@@ -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();
diff --git a/backend/controllers/clubTaskController.js b/backend/controllers/clubTaskController.js
index 49485922..d27cc406 100644
--- a/backend/controllers/clubTaskController.js
+++ b/backend/controllers/clubTaskController.js
@@ -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,
});
diff --git a/backend/controllers/clubsController.js b/backend/controllers/clubsController.js
index b91cdf3d..c27cb3f0 100644
--- a/backend/controllers/clubsController.js
+++ b/backend/controllers/clubsController.js
@@ -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,
diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js
index a6f01394..5ca5ed74 100644
--- a/backend/controllers/memberController.js
+++ b/backend/controllers/memberController.js
@@ -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) {
diff --git a/backend/migrations/create_calendar_events_table.sql b/backend/migrations/create_calendar_events_table.sql
index 5d3dbc38..60f8a697 100644
--- a/backend/migrations/create_calendar_events_table.sql
+++ b/backend/migrations/create_calendar_events_table.sql
@@ -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
diff --git a/backend/models/CalendarEvent.js b/backend/models/CalendarEvent.js
index 44fcc7fb..02fcd1b8 100644
--- a/backend/models/CalendarEvent.js
+++ b/backend/models/CalendarEvent.js
@@ -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;
diff --git a/backend/models/Club.js b/backend/models/Club.js
index 6a5a8327..45cf0afa 100644
--- a/backend/models/Club.js
+++ b/backend/models/Club.js
@@ -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,
diff --git a/backend/models/ClubAccountTransaction.js b/backend/models/ClubAccountTransaction.js
new file mode 100644
index 00000000..221a6587
--- /dev/null
+++ b/backend/models/ClubAccountTransaction.js
@@ -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;
diff --git a/backend/models/ClubCommunicationDeliveryLog.js b/backend/models/ClubCommunicationDeliveryLog.js
new file mode 100644
index 00000000..610bea03
--- /dev/null
+++ b/backend/models/ClubCommunicationDeliveryLog.js
@@ -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;
diff --git a/backend/models/ClubCommunicationMessage.js b/backend/models/ClubCommunicationMessage.js
new file mode 100644
index 00000000..86726d30
--- /dev/null
+++ b/backend/models/ClubCommunicationMessage.js
@@ -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;
diff --git a/backend/models/ClubCommunicationRecipient.js b/backend/models/ClubCommunicationRecipient.js
new file mode 100644
index 00000000..355d3cb1
--- /dev/null
+++ b/backend/models/ClubCommunicationRecipient.js
@@ -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;
diff --git a/backend/models/ClubCommunicationTemplate.js b/backend/models/ClubCommunicationTemplate.js
new file mode 100644
index 00000000..104e79b8
--- /dev/null
+++ b/backend/models/ClubCommunicationTemplate.js
@@ -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;
diff --git a/backend/models/ClubCommunicationThread.js b/backend/models/ClubCommunicationThread.js
new file mode 100644
index 00000000..a5426c5f
--- /dev/null
+++ b/backend/models/ClubCommunicationThread.js
@@ -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;
diff --git a/backend/models/ClubDistributionGroup.js b/backend/models/ClubDistributionGroup.js
new file mode 100644
index 00000000..72432bb5
--- /dev/null
+++ b/backend/models/ClubDistributionGroup.js
@@ -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;
diff --git a/backend/models/ClubDistributionGroupMember.js b/backend/models/ClubDistributionGroupMember.js
new file mode 100644
index 00000000..c5767188
--- /dev/null
+++ b/backend/models/ClubDistributionGroupMember.js
@@ -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;
diff --git a/backend/models/ClubDocument.js b/backend/models/ClubDocument.js
new file mode 100644
index 00000000..087bda9c
--- /dev/null
+++ b/backend/models/ClubDocument.js
@@ -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;
diff --git a/backend/models/ClubDocumentLink.js b/backend/models/ClubDocumentLink.js
new file mode 100644
index 00000000..5f2a58ce
--- /dev/null
+++ b/backend/models/ClubDocumentLink.js
@@ -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;
diff --git a/backend/models/ClubDocumentVersion.js b/backend/models/ClubDocumentVersion.js
new file mode 100644
index 00000000..5aabf0c4
--- /dev/null
+++ b/backend/models/ClubDocumentVersion.js
@@ -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;
diff --git a/backend/models/ClubInvoiceParty.js b/backend/models/ClubInvoiceParty.js
index 7839e0e9..b8f773d3 100644
--- a/backend/models/ClubInvoiceParty.js
+++ b/backend/models/ClubInvoiceParty.js
@@ -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;
diff --git a/backend/models/ClubPaymentClaim.js b/backend/models/ClubPaymentClaim.js
index 26389a46..7c8d85db 100644
--- a/backend/models/ClubPaymentClaim.js
+++ b/backend/models/ClubPaymentClaim.js
@@ -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,
diff --git a/backend/models/Member.js b/backend/models/Member.js
index 2d7bda58..60af45fa 100644
--- a/backend/models/Member.js
+++ b/backend/models/Member.js
@@ -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,
diff --git a/backend/models/index.js b/backend/models/index.js
index 2d14177a..476afaf7 100644
--- a/backend/models/index.js
+++ b/backend/models/index.js
@@ -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,
};
diff --git a/backend/routes/calendarEventRoutes.js b/backend/routes/calendarEventRoutes.js
index 2dd2f25c..0411a4e7 100644
--- a/backend/routes/calendarEventRoutes.js
+++ b/backend/routes/calendarEventRoutes.js
@@ -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;
diff --git a/backend/routes/clubAccountRoutes.js b/backend/routes/clubAccountRoutes.js
index c1173656..ff798ed3 100644
--- a/backend/routes/clubAccountRoutes.js
+++ b/backend/routes/clubAccountRoutes.js
@@ -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;
diff --git a/backend/routes/clubArchiveRoutes.js b/backend/routes/clubArchiveRoutes.js
index 88435ed1..b3b3dac9 100644
--- a/backend/routes/clubArchiveRoutes.js
+++ b/backend/routes/clubArchiveRoutes.js
@@ -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;
diff --git a/backend/routes/clubCommunicationRoutes.js b/backend/routes/clubCommunicationRoutes.js
new file mode 100644
index 00000000..aecc47f6
--- /dev/null
+++ b/backend/routes/clubCommunicationRoutes.js
@@ -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;
diff --git a/backend/routes/clubDocumentRoutes.js b/backend/routes/clubDocumentRoutes.js
new file mode 100644
index 00000000..4a4da07b
--- /dev/null
+++ b/backend/routes/clubDocumentRoutes.js
@@ -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;
diff --git a/backend/routes/clubInvoiceRoutes.js b/backend/routes/clubInvoiceRoutes.js
index 00ecbd63..25234df5 100644
--- a/backend/routes/clubInvoiceRoutes.js
+++ b/backend/routes/clubInvoiceRoutes.js
@@ -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;
diff --git a/backend/routes/clubPaymentClaimRoutes.js b/backend/routes/clubPaymentClaimRoutes.js
new file mode 100644
index 00000000..aa53c70a
--- /dev/null
+++ b/backend/routes/clubPaymentClaimRoutes.js
@@ -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;
diff --git a/backend/routes/clubRequestRoutes.js b/backend/routes/clubRequestRoutes.js
index d4f045b0..04260a63 100644
--- a/backend/routes/clubRequestRoutes.js
+++ b/backend/routes/clubRequestRoutes.js
@@ -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;
diff --git a/backend/routes/clubTaskRoutes.js b/backend/routes/clubTaskRoutes.js
index e0f16921..2d5736c6 100644
--- a/backend/routes/clubTaskRoutes.js
+++ b/backend/routes/clubTaskRoutes.js
@@ -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;
diff --git a/backend/server.js b/backend/server.js
index 535e8f46..352d473c 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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);
diff --git a/backend/services/calendarEventService.js b/backend/services/calendarEventService.js
index d332ab47..565a21eb 100644
--- a/backend/services/calendarEventService.js
+++ b/backend/services/calendarEventService.js
@@ -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();
diff --git a/backend/services/clubAccountService.js b/backend/services/clubAccountService.js
index 5fb74ac4..9a808b12 100644
--- a/backend/services/clubAccountService.js
+++ b/backend/services/clubAccountService.js
@@ -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'],
],
});
}
diff --git a/backend/services/clubCommunicationService.js b/backend/services/clubCommunicationService.js
new file mode 100644
index 00000000..e559922a
--- /dev/null
+++ b/backend/services/clubCommunicationService.js
@@ -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, '"');
+}
+
+function looksLikeHtml(value = '') {
+ return /<\/?[a-z][\s\S]*>/i.test(String(value || ''));
+}
+
+function stripHtml(value = '') {
+ return String(value || '')
+ .replace(/
/gi, '\n')
+ .replace(/<\/p>\s*/gi, '\n\n')
+ .replace(/<\/div>\s*
/gi, '\n')
+ .replace(/
]*>/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 `${String(body || '')}
`;
+ }
+ return `${escapeHtml(body).replace(/\n/g, '
')}
`;
+}
+
+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();
diff --git a/backend/services/clubDocumentService.js b/backend/services/clubDocumentService.js
new file mode 100644
index 00000000..ffd1a0da
--- /dev/null
+++ b/backend/services/clubDocumentService.js
@@ -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();
diff --git a/backend/services/clubInvoiceService.js b/backend/services/clubInvoiceService.js
index 07039523..64feda04 100644
--- a/backend/services/clubInvoiceService.js
+++ b/backend/services/clubInvoiceService.js
@@ -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 });
});
diff --git a/backend/services/clubPaymentClaimService.js b/backend/services/clubPaymentClaimService.js
new file mode 100644
index 00000000..a62f6458
--- /dev/null
+++ b/backend/services/clubPaymentClaimService.js
@@ -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();
diff --git a/backend/services/clubService.js b/backend/services/clubService.js
index 80b23f55..e04e1241 100644
--- a/backend/services/clubService.js
+++ b/backend/services/clubService.js
@@ -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;
diff --git a/backend/services/clubStatisticsService.js b/backend/services/clubStatisticsService.js
index f0abac9b..15963686 100644
--- a/backend/services/clubStatisticsService.js
+++ b/backend/services/clubStatisticsService.js
@@ -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;
}
}
}
diff --git a/backend/services/clubTaskAutomationService.js b/backend/services/clubTaskAutomationService.js
index a49c4984..c3e05dde 100644
--- a/backend/services/clubTaskAutomationService.js
+++ b/backend/services/clubTaskAutomationService.js
@@ -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;
}
diff --git a/backend/services/clubTaskDefinitions.js b/backend/services/clubTaskDefinitions.js
index be76517b..f5b1a1f9 100644
--- a/backend/services/clubTaskDefinitions.js
+++ b/backend/services/clubTaskDefinitions.js
@@ -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;
diff --git a/backend/services/clubWorkflowSourceService.js b/backend/services/clubWorkflowSourceService.js
index 8d960aab..7f7d299b 100644
--- a/backend/services/clubWorkflowSourceService.js
+++ b/backend/services/clubWorkflowSourceService.js
@@ -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;
}
diff --git a/backend/services/emailService.js b/backend/services/emailService.js
index 7eba4337..ebe55f48 100644
--- a/backend/services/emailService.js
+++ b/backend/services/emailService.js
@@ -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) => {
`,
- };
- await transporter.sendMail(mailOptions);
+ });
};
const sendFriendlyMatchInvitationEmail = async ({
@@ -68,8 +89,7 @@ const sendFriendlyMatchInvitationEmail = async ({
? `Nachricht:
${String(message).replace(//g, '>')}
`
: '';
- 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 ({
`,
- };
-
- 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 ({
${escapeHtml(message)}
`,
- };
-
- 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 };
diff --git a/backend/services/memberService.js b/backend/services/memberService.js
index 2ca8f808..6073a38b 100644
--- a/backend/services/memberService.js
+++ b/backend/services/memberService.js
@@ -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
diff --git a/backend/services/permissionService.js b/backend/services/permissionService.js
index ca74d89a..f4aa18ff 100644
--- a/backend/services/permissionService.js
+++ b/backend/services/permissionService.js
@@ -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;
diff --git a/docs/TODO.md b/docs/TODO.md
index 7395ff41..aa57b1bb 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -1,6 +1,6 @@
# TODO
-Stand: 2026-03-17
+Stand: 2026-06-22
## Abgearbeitet
@@ -9,13 +9,67 @@ Stand: 2026-03-17
- [x] Sichtbare UI-Konsistenz an Diary-Mobile-Tabs und Logs-Ansicht nachgezogen.
- [x] Live-SQL fuer neue Felder und manuelle Migrationen dokumentiert.
- [x] Scheduler- und `match_results`-Ablauf dokumentiert.
+- [x] Produkttrennung fuer `tt-verein.de` und `mein-tt.de` technisch eingefuehrt.
+- [x] Vereinsnavigation und Routing fuer das Club-Produkt produkt- und rechtebasiert aufgebaut.
+- [x] Dashboard von Dummy-Daten auf echte Vereinsdaten fuer Aufgaben, Mitglieder, Termine und fehlende Daten umgestellt.
+- [x] Aufgabenmodul mit echten Aufgaben, automatischen Vorschlaegen, Ausblenden, Archivieren, Loeschen und Benutzerzuordnung umgesetzt.
+- [x] Rollen und Benutzer fuer Vereine eingefuehrt, inklusive Mehrfachrollen und menuewirksamer Rechtepruefung.
+- [x] Historie fuer Vereinsbereiche umgesetzt und Aenderungen an Rollen und Benutzerzuordnungen aufgenommen.
+- [x] Mitgliederbereich um Bankkonto-/SEPA-relevante Vereinsdaten erweitert.
+- [x] Statistiken als echte, einklappbare Vereinsauswertungen umgesetzt.
+- [x] Archiv als echte Vereinsansicht umgesetzt.
+- [x] Konten und Rechnungen als echte Vereinsmodule aufgebaut.
+- [x] Kommunikation als Vereinsmodul mit Vorgaengen, Verteilergruppen, Empfaengerlogik und Versandstatus umgesetzt.
+- [x] Echten Mailversand fuer Kommunikation mit retry-faehiger Fehlerklassifikation, Versandprotokoll und sichtbaren Fehlermeldungen eingebaut.
+- [x] Rechtebasierte Club-Navigation auf fachlich passende Module und Berechtigungen fuer Aufgaben, Kommunikation, Historie, Archiv, Konten und Rechnungen nachgezogen.
-## Weiter spaeter sinnvoll
+## Teilweise umgesetzt / weiter ausbauen
- [x] Groeßere Views weiter komponentisieren, vor allem `DiaryView.vue`, `MembersView.vue`, `TeamManagementView.vue`.
- [x] Verbleibende selten genutzte Alt-Styles in Spezialviews und Demo-Komponenten angleichen.
- [x] Diary-Sonderfaelle weiter schaerfen, z.B. eigene Filterchips fuer entschuldigte Teilnehmer.
+- [x] Club-Views weiter haerten: Read-only-Verhalten, Reload-Zustaende und Formular-Resets in Aufgaben, Kommunikation, Konten und Rechnungen weiter auf Kantenfaelle pruefen.
+- [ ] Kommunikation: SMTP-Konfiguration produktiv pruefen, reale Zustellung testen und optional Antwortadressen pro Verein nachziehen.
+- [x] Rechnungen: automatische Nummernvergabe aus Vereineinstellungen fertig verdrahten und weiter absichern.
+- [x] Aufgabenautomatisierung weiter ausbauen, damit noch mehr Vereinsprozesse automatisch Folgeschritte erzeugen.
+- [x] Dashboard weiter verdichten, damit neue Kommunikations-, Finanz- und Archivdaten direkter sichtbar werden.
+- [x] Beitraege/Zahlungen operativ zuerst verdichten statt sofort Regelwerk bauen:
+- [x] Mitglieder sauber mit Beitragsgruppe und Zahlungsbezug sichtbar verknuepfen.
+- [x] In der Beitraege-Ansicht pro Mitglied offene Forderungen, letzten Status und fehlende Zuordnungen sichtbar machen.
+- [x] In der Zahlungen-Ansicht Forderungen, Mahnstufen und offene Aktionen als taegliche Arbeitsliste nutzbar machen.
+- [x] Danach einfache manuelle Beitragslogik fuer typische Vereinsfaelle ergaenzen.
+- [ ] Erst spaeter ein allgemeines Regel-/Tarifsystem mit Familienregeln, Alterslogik und Gueltigkeitszeitraeumen bauen.
-## Naechste Liste
+## Naechste Prioritaeten
-Die neue priorisierte Restliste steht in [OPTIMIZATION_TODO.md](./OPTIMIZATION_TODO.md).
+- [x] Kommunikation: Empfaengerlogik weiter ausbauen, Versandvorlagen, Verteilerfilter und Antworten dokumentieren.
+- [x] Finanzen: Ausgangs- und Eingangsrechnungen weiter vervollstaendigen, Kontenbewegungen anbinden.
+- [x] Vereinsbenutzer: feinere Rechte, Rollenpflege und weitere Menueeinschraenkungen vervollstaendigen.
+- [x] Automatisierte Vereinsprozesse definieren und technisch als stabile Aufgabenquellen hinterlegen.
+- [x] Beitraege/Zahlungen: zuerst Mitglied -> Beitragszuordnung -> Forderung -> Zahlung als durchgehenden Vereinsprozess fertigziehen.
+- [x] Beitraege: Mitgliederliste mit Beitragsgruppe, offenen Forderungen und fehlenden Zuordnungen verdichten.
+- [x] Zahlungen: Forderungen, Statuswechsel und Mahnlogik weiter zu einem echten Vereinsarbeitsbereich ausbauen.
+- [ ] Player-Produkt `mein-tt.de` inhaltlich ausbauen, jetzt wo die Produkttrennung steht.
+
+## Weiter spaeter sinnvoll
+
+- [ ] Historie feiner filtern, exportieren und moduluebergreifend verlinken.
+- [ ] Kommunikation um Dokumentanhaenge und Serienvorlagen erweitern.
+- [ ] Vereinsarchiv um weitere Entitaeten und komfortablere Suche erweitern.
+- [ ] Die alte Optimierungs-Restliste bei Bedarf mit [OPTIMIZATION_TODO.md](./OPTIMIZATION_TODO.md) zusammenfuehren.
+
+## Fehlend
+
+- [ ] Club-Produkt: Die folgenden sechs Vereinsmodule sind in der Struktur vorhanden, aber noch nicht als echte Arbeitsbereiche umgesetzt.
+- [x] Dokumente: Upload, Ordner-/Ablagestruktur, Belegbezug und schnelle Suche als echter Vereins-Dokumentenbereich.
+- [x] Dokumente: Vorlagen, Versionierung und Archivierung so nachziehen, dass Satzung, Protokolle und Nachweise sauber verwaltbar sind.
+- [x] Veranstaltungen: Vereinsveranstaltungen mit Fristen, Verantwortlichkeiten und Aufgabenverknuepfung als eigenes Modul umsetzen.
+- [x] Veranstaltungen: Terminarten, Statuswechsel und Nacharbeit fuer Planung, Einladung und Durchfuehrung trennen.
+- [x] Sponsoren: Sponsorenstamm mit Ansprechpartnern, Laufzeiten und Vertragsbezug als echte Pflegeansicht aufbauen.
+- [x] Sponsoren: Sponsoringanfragen, laufende Beziehungen und zugehoerige Rechnungen in einem Arbeitsfluss verbinden.
+- [x] Beiträge: Beitragssätze, Familienmodelle und Ermäßigungen als vereinfachte manuelle Regelbasis modellieren.
+- [x] Beiträge: Beitragsgruppen, Beitragszuordnung und Zahlungsbezug in Mitglieder- und Finanzsicht konsistent halten.
+- [x] Zahlungen: Offene Forderungen, Zahlungseingänge, Mahnstufen und Statuswechsel als tägliche Arbeitsliste weiter schärfen.
+- [x] Zahlungen: Kontobewegungen, Teilzahlungen und automatische Zuordnung zu Forderungen stabil mit dem Mitgliederbezug verbinden.
+- [x] Berichte: Vorstand, Finanzen, Archiv und Sponsoring mit echten Auswertungen und Exporten aus den vorhandenen Daten versorgen.
+- [x] Berichte: Wiederkehrende Reportsets, PDF-Ausgabe und Vorlagen fuer Standardauswertungen vorbereiten.
diff --git a/docs/club-communication-workflow.md b/docs/club-communication-workflow.md
new file mode 100644
index 00000000..c141345e
--- /dev/null
+++ b/docs/club-communication-workflow.md
@@ -0,0 +1,34 @@
+# Kommunikation im Verein
+
+Stand: 2026-06-22
+
+## Empfängerlogik
+
+- Einzelvorgänge adressieren genau ein Mitglied.
+- Gruppenvorgänge nutzen eine Verteilergruppe plus optionale Filter.
+- Rundschreiben arbeiten ohne feste Gruppe und filtern direkt auf dem Vorgang.
+
+## Verfügbare Filter
+
+- `Nur aktive Mitglieder`
+- `Nur mit E-Mail-Adresse`
+- `Nur mit aktivem SEPA-Mandat`
+- `Nur ohne aktives SEPA-Mandat`
+
+Diese Filter werden beim Auflösen der Empfängerliste technisch berücksichtigt und nicht nur im Frontend angezeigt.
+
+## Versandstatus
+
+- `Ausstehend`: noch kein erfolgreicher Versand.
+- `Gesendet`: erfolgreich zugestellt.
+- `Fehlgeschlagen`: technischer oder fachlicher Fehler.
+- `Übersprungen`: bewusst nicht versendet, z. B. ohne E-Mail-Adresse.
+
+Retry ist nur erlaubt, wenn der Fehler als retryfähig klassifiziert wurde, z. B. bei Zeitüberschreitungen oder temporären SMTP-Antworten.
+
+## Vorlagen und Antworten
+
+- Vorlagen enthalten Name, Kategorie, Betreff-Vorlage und Text-Vorlage.
+- `Platzhalter / Antworten dokumentieren` dient als technische Dokumentation für Variablen, Antwortvorgaben und Standardformulierungen pro Verein.
+- Beim Schreiben einer Nachricht kann eine Vorlage direkt in Betreff und Nachrichtentext übernommen werden.
+- Eingehende Rückmeldungen werden als Richtung `Eingehend` im Vorgang protokolliert.
diff --git a/docs/club-task-workflow-sources.md b/docs/club-task-workflow-sources.md
new file mode 100644
index 00000000..379a4f59
--- /dev/null
+++ b/docs/club-task-workflow-sources.md
@@ -0,0 +1,31 @@
+# Automatisierte Aufgabenquellen
+
+Stand: 2026-06-22
+
+## Stabile Quellen
+
+- `club_requests`
+ Basis: Kontakt-, Probetraining-, Mitgliedschafts- und Sponsoringanfragen.
+- `members`
+ Basis: Mitgliedsdaten, fehlende Pflichtangaben, Statuswechsel.
+- `club_sepa_mandates`
+ Basis: fehlende, widerrufene oder ablaufrelevante SEPA-Mandate.
+- `club_payment_claims`
+ Basis: offene, teilweise bezahlte oder überfällige Beitragsforderungen.
+- `club_invoices`
+ Basis: eingehende und ausgehende Rechnungen mit Fälligkeiten und Status.
+- `club_communication`
+ Basis: fehlgeschlagene Zustellversuche mit retryfähigen Fehlern.
+- `calendar_events`
+ Basis: Termine, Fristen und Vereinsveranstaltungen.
+
+## Technisches Verhalten
+
+- Jede automatische Aufgabe erhält `automation_source`, `automation_key` und `source_snapshot`.
+- `automation_key` dient zur Deduplizierung.
+- Ausgeblendete Vorschläge wirken vereinsweit, nicht nur pro Benutzer.
+- Aufgabenquellen werden im Aufgabenmodul sichtbar gemacht, damit nachvollziehbar bleibt, woher ein Vorschlag kommt.
+
+## Ziel
+
+Die Automatisierung soll keine Blackbox sein. Jeder Vorschlag muss auf einen stabilen Quelldatensatz zurückführbar und nach Änderungen reproduzierbar sein.
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index da7ef0ac..a4bbf360 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,9 +11,11 @@
"axios": "^1.7.3",
"core-js": "^3.8.3",
"crypto-js": "^4.2.0",
+ "docx": "^9.7.1",
"html2canvas": "^1.4.1",
"jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.2",
+ "jszip": "^3.10.1",
"node-cron": "^4.2.1",
"pdfjs-dist": "^5.6.205",
"socket.io-client": "^4.8.1",
@@ -1708,6 +1710,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "25.9.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz",
+ "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
@@ -2120,6 +2131,12 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2226,6 +2243,41 @@
"node": ">=0.10"
}
},
+ "node_modules/docx": {
+ "version": "9.7.1",
+ "resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
+ "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^25.2.3",
+ "hash.js": "^1.1.7",
+ "jszip": "^3.10.1",
+ "nanoid": "^5.1.3",
+ "xml": "^1.0.1",
+ "xml-js": "^1.6.8"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/docx/node_modules/nanoid": {
+ "version": "5.1.15",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.15.tgz",
+ "integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^18 || >=20"
+ }
+ },
"node_modules/dompurify": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
@@ -2904,6 +2956,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/hash.js": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
+ "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "minimalistic-assert": "^1.0.1"
+ }
+ },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -2960,6 +3022,12 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -2998,7 +3066,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/iobuffer": {
@@ -3041,6 +3108,12 @@
"node": ">=0.12.0"
}
},
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3108,6 +3181,24 @@
"jspdf": "^2 || ^3 || ^4"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/jszip/node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3132,6 +3223,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3216,6 +3316,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "license": "ISC"
+ },
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -3506,6 +3612,12 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -3532,6 +3644,21 @@
"performance-now": "^2.1.0"
}
},
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3618,6 +3745,12 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
"node_modules/sass": {
"version": "1.89.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz",
@@ -3680,6 +3813,15 @@
}
}
},
+ "node_modules/sax": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
+ "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -3693,6 +3835,12 @@
"node": ">=10"
}
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -3803,6 +3951,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -3953,6 +4110,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "license": "MIT"
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -3967,7 +4130,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true,
"license": "MIT"
},
"node_modules/utrie": {
@@ -4288,6 +4450,24 @@
}
}
},
+ "node_modules/xml": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
+ "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
+ "license": "MIT"
+ },
+ "node_modules/xml-js": {
+ "version": "1.6.11",
+ "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
+ "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": "^1.2.4"
+ },
+ "bin": {
+ "xml-js": "bin/cli.js"
+ }
+ },
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 3ffc805a..80cf0550 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,9 +19,11 @@
"axios": "^1.7.3",
"core-js": "^3.8.3",
"crypto-js": "^4.2.0",
+ "docx": "^9.7.1",
"html2canvas": "^1.4.1",
"jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.2",
+ "jszip": "^3.10.1",
"node-cron": "^4.2.1",
"pdfjs-dist": "^5.6.205",
"socket.io-client": "^4.8.1",
diff --git a/frontend/sql/tt-verein-v1-schema.mysql.sql b/frontend/sql/tt-verein-v1-schema.mysql.sql
index 7823bacb..766767ed 100644
--- a/frontend/sql/tt-verein-v1-schema.mysql.sql
+++ b/frontend/sql/tt-verein-v1-schema.mysql.sql
@@ -16,6 +16,7 @@ ALTER TABLE `clubs`
ADD COLUMN IF NOT EXISTS `billing_email` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `iban` varchar(34) NULL,
ADD COLUMN IF NOT EXISTS `bic` varchar(11) NULL,
+ ADD COLUMN IF NOT EXISTS `fee_rules` json NULL,
ADD COLUMN IF NOT EXISTS `outgoing_invoice_prefix` varchar(24) NOT NULL DEFAULT 'RE',
ADD COLUMN IF NOT EXISTS `outgoing_invoice_next_number` int NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS `incoming_invoice_prefix` varchar(24) NOT NULL DEFAULT 'EI',
@@ -61,6 +62,148 @@ CREATE TABLE IF NOT EXISTS `club_requests` (
KEY `idx_club_requests_club_status` (`club_id`, `status`, `request_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+CREATE TABLE IF NOT EXISTS `club_distribution_groups` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `club_id` bigint NOT NULL,
+ `name` varchar(255) NOT NULL,
+ `description` text NULL,
+ `group_type` varchar(32) NOT NULL DEFAULT 'custom',
+ `is_system_group` tinyint(1) NOT NULL DEFAULT 0,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_club_distribution_groups_club` (`club_id`, `name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `club_distribution_group_members` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `group_id` bigint NOT NULL,
+ `member_id` bigint NOT NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uniq_club_distribution_group_member` (`group_id`, `member_id`),
+ KEY `idx_club_distribution_group_members_member` (`member_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `club_communication_threads` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `club_id` bigint NOT NULL,
+ `thread_type` varchar(32) NOT NULL DEFAULT 'direct',
+ `subject` varchar(255) NOT NULL,
+ `status` varchar(32) NOT NULL DEFAULT 'draft',
+ `created_by_user_id` bigint NULL,
+ `distribution_group_id` bigint NULL,
+ `recipient_member_id` bigint NULL,
+ `scheduled_at` datetime NULL,
+ `sent_at` datetime NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_club_communication_threads_club` (`club_id`, `status`, `thread_type`),
+ KEY `idx_club_communication_threads_group` (`distribution_group_id`),
+ KEY `idx_club_communication_threads_member` (`recipient_member_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `club_communication_messages` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `thread_id` bigint NOT NULL,
+ `club_id` bigint NOT NULL,
+ `message_type` varchar(32) NOT NULL DEFAULT 'message',
+ `direction` varchar(32) NOT NULL DEFAULT 'outbound',
+ `body` text NOT NULL,
+ `created_by_user_id` bigint NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_club_communication_messages_thread` (`thread_id`, `created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `club_communication_recipients` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `thread_id` bigint NOT NULL,
+ `club_id` bigint NOT NULL,
+ `member_id` bigint NULL,
+ `recipient_name` varchar(255) NOT NULL,
+ `email_snapshot` varchar(255) NULL,
+ `delivery_status` varchar(32) NOT NULL DEFAULT 'pending',
+ `delivered_at` datetime NULL,
+ `last_attempt_at` datetime NULL,
+ `attempt_count` int NOT NULL DEFAULT 0,
+ `retryable` tinyint(1) NOT NULL DEFAULT 0,
+ `error_code` varchar(64) NULL,
+ `transport_message_id` varchar(255) NULL,
+ `error_message` text NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_club_communication_recipients_thread` (`thread_id`, `delivery_status`),
+ KEY `idx_club_communication_recipients_member` (`member_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `club_communication_delivery_logs` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `club_id` bigint NOT NULL,
+ `thread_id` bigint NOT NULL,
+ `recipient_id` bigint NULL,
+ `created_by_user_id` bigint NULL,
+ `status` varchar(32) NOT NULL DEFAULT 'failed',
+ `attempt_no` int NOT NULL DEFAULT 1,
+ `retryable` tinyint(1) NOT NULL DEFAULT 0,
+ `error_code` varchar(64) NULL,
+ `error_message` text NULL,
+ `transport_message_id` varchar(255) NULL,
+ `transport_response` text NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_club_communication_delivery_logs_thread` (`thread_id`, `created_at`),
+ KEY `idx_club_communication_delivery_logs_recipient` (`recipient_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+ALTER TABLE `club_distribution_groups`
+ ADD COLUMN IF NOT EXISTS `filter_definition` json NULL;
+
+ALTER TABLE `club_communication_threads`
+ ADD COLUMN IF NOT EXISTS `recipient_filters` json NULL;
+
+CREATE TABLE IF NOT EXISTS `club_communication_templates` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `club_id` bigint NOT NULL,
+ `name` varchar(160) NOT NULL,
+ `category` varchar(64) NOT NULL DEFAULT 'general',
+ `subject_template` varchar(255) NULL,
+ `body_template` text NULL,
+ `variables_hint` text NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_club_communication_templates_club` (`club_id`, `category`, `name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS `club_account_transactions` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `club_id` bigint NOT NULL,
+ `account_id` bigint NOT NULL,
+ `invoice_id` bigint NULL,
+ `created_by_user_id` bigint NULL,
+ `direction` varchar(16) NOT NULL DEFAULT 'credit',
+ `booking_type` varchar(32) NOT NULL DEFAULT 'manual',
+ `status` varchar(32) NOT NULL DEFAULT 'booked',
+ `booking_date` date NOT NULL,
+ `value_date` date NULL,
+ `amount_cents` bigint NOT NULL DEFAULT 0,
+ `currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
+ `reference` varchar(255) NULL,
+ `notes` text NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_club_account_transactions_club_date` (`club_id`, `booking_date`, `status`),
+ KEY `idx_club_account_transactions_account_date` (`account_id`, `booking_date`),
+ KEY `idx_club_account_transactions_invoice` (`invoice_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
CREATE TABLE IF NOT EXISTS `club_request_notes` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_request_id` bigint NOT NULL,
@@ -209,7 +352,11 @@ CREATE TABLE IF NOT EXISTS `club_invoice_parties` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`party_type` varchar(32) NOT NULL DEFAULT 'customer',
+ `status` varchar(32) NOT NULL DEFAULT 'active',
`name` varchar(255) NOT NULL,
+ `contract_reference` varchar(120) NULL,
+ `valid_from` date NULL,
+ `valid_to` date NULL,
`contact_name` varchar(255) NULL,
`email` varchar(255) NULL,
`phone` varchar(80) NULL,
@@ -224,7 +371,10 @@ CREATE TABLE IF NOT EXISTS `club_invoice_parties` (
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
- KEY `idx_club_invoice_parties_club_type` (`club_id`, `party_type`)
+ KEY `idx_club_invoice_parties_club_type` (`club_id`, `party_type`),
+ KEY `idx_club_invoice_parties_club_status` (`club_id`, `status`),
+ KEY `idx_club_invoice_parties_club_valid_from` (`club_id`, `valid_from`),
+ KEY `idx_club_invoice_parties_club_valid_to` (`club_id`, `valid_to`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_invoices` (
@@ -251,9 +401,26 @@ CREATE TABLE IF NOT EXISTS `club_invoices` (
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`archived_at` datetime NULL,
PRIMARY KEY (`id`),
- KEY `idx_club_invoices_club_direction_status` (`club_id`, `invoice_direction`, `status`, `due_on`)
+ KEY `idx_club_invoices_club_direction_status` (`club_id`, `invoice_direction`, `status`, `due_on`),
+ UNIQUE KEY `uq_club_invoices_number` (`club_id`, `invoice_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+SET @club_invoices_number_index_exists := (
+ SELECT COUNT(*)
+ FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'club_invoices'
+ AND index_name = 'uq_club_invoices_number'
+);
+SET @club_invoices_number_index_sql := IF(
+ @club_invoices_number_index_exists = 0,
+ 'ALTER TABLE `club_invoices` ADD UNIQUE KEY `uq_club_invoices_number` (`club_id`, `invoice_number`)',
+ 'SELECT 1'
+);
+PREPARE club_invoices_number_index_stmt FROM @club_invoices_number_index_sql;
+EXECUTE club_invoices_number_index_stmt;
+DEALLOCATE PREPARE club_invoices_number_index_stmt;
+
CREATE TABLE IF NOT EXISTS `club_invoice_items` (
`id` bigint NOT NULL AUTO_INCREMENT,
`invoice_id` bigint NOT NULL,
@@ -268,3 +435,30 @@ CREATE TABLE IF NOT EXISTS `club_invoice_items` (
PRIMARY KEY (`id`),
UNIQUE KEY `uq_club_invoice_items_line` (`invoice_id`, `line_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+ALTER TABLE `club_communication_recipients`
+ ADD COLUMN IF NOT EXISTS `last_attempt_at` datetime NULL,
+ ADD COLUMN IF NOT EXISTS `attempt_count` int NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS `retryable` tinyint(1) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS `error_code` varchar(64) NULL,
+ ADD COLUMN IF NOT EXISTS `transport_message_id` varchar(255) NULL;
+
+CREATE TABLE IF NOT EXISTS `club_communication_delivery_logs` (
+ `id` bigint NOT NULL AUTO_INCREMENT,
+ `club_id` bigint NOT NULL,
+ `thread_id` bigint NOT NULL,
+ `recipient_id` bigint NULL,
+ `created_by_user_id` bigint NULL,
+ `status` varchar(32) NOT NULL DEFAULT 'failed',
+ `attempt_no` int NOT NULL DEFAULT 1,
+ `retryable` tinyint(1) NOT NULL DEFAULT 0,
+ `error_code` varchar(64) NULL,
+ `error_message` text NULL,
+ `transport_message_id` varchar(255) NULL,
+ `transport_response` text NULL,
+ `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `idx_club_communication_delivery_logs_thread` (`thread_id`, `created_at`),
+ KEY `idx_club_communication_delivery_logs_recipient` (`recipient_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
diff --git a/frontend/sql/tt-verein-v1-schema.sql b/frontend/sql/tt-verein-v1-schema.sql
index 0a91578a..7d20825e 100644
--- a/frontend/sql/tt-verein-v1-schema.sql
+++ b/frontend/sql/tt-verein-v1-schema.sql
@@ -21,6 +21,7 @@ ALTER TABLE IF EXISTS clubs
ADD COLUMN IF NOT EXISTS billing_email varchar(255),
ADD COLUMN IF NOT EXISTS iban varchar(34),
ADD COLUMN IF NOT EXISTS bic varchar(11),
+ ADD COLUMN IF NOT EXISTS fee_rules jsonb,
ADD COLUMN IF NOT EXISTS outgoing_invoice_prefix varchar(24) NOT NULL DEFAULT 'RE',
ADD COLUMN IF NOT EXISTS outgoing_invoice_next_number integer NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS incoming_invoice_prefix varchar(24) NOT NULL DEFAULT 'EI',
@@ -67,6 +68,110 @@ CREATE TABLE IF NOT EXISTS club_requests (
updated_at timestamptz NOT NULL DEFAULT now()
);
+CREATE TABLE IF NOT EXISTS club_distribution_groups (
+ id bigserial PRIMARY KEY,
+ club_id bigint NOT NULL,
+ name varchar(255) NOT NULL,
+ description text,
+ group_type varchar(32) NOT NULL DEFAULT 'custom',
+ is_system_group boolean NOT NULL DEFAULT false,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_club_distribution_groups_club
+ ON club_distribution_groups (club_id, name);
+
+CREATE TABLE IF NOT EXISTS club_distribution_group_members (
+ id bigserial PRIMARY KEY,
+ group_id bigint NOT NULL,
+ member_id bigint NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (group_id, member_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_club_distribution_group_members_member
+ ON club_distribution_group_members (member_id);
+
+CREATE TABLE IF NOT EXISTS club_communication_threads (
+ id bigserial PRIMARY KEY,
+ club_id bigint NOT NULL,
+ thread_type varchar(32) NOT NULL DEFAULT 'direct',
+ subject varchar(255) NOT NULL,
+ status varchar(32) NOT NULL DEFAULT 'draft',
+ created_by_user_id bigint,
+ distribution_group_id bigint,
+ recipient_member_id bigint,
+ scheduled_at timestamptz,
+ sent_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_club_communication_threads_club
+ ON club_communication_threads (club_id, status, thread_type);
+
+CREATE TABLE IF NOT EXISTS club_communication_messages (
+ id bigserial PRIMARY KEY,
+ thread_id bigint NOT NULL,
+ club_id bigint NOT NULL,
+ message_type varchar(32) NOT NULL DEFAULT 'message',
+ direction varchar(32) NOT NULL DEFAULT 'outbound',
+ body text NOT NULL,
+ created_by_user_id bigint,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_club_communication_messages_thread
+ ON club_communication_messages (thread_id, created_at DESC);
+
+CREATE TABLE IF NOT EXISTS club_communication_recipients (
+ id bigserial PRIMARY KEY,
+ thread_id bigint NOT NULL,
+ club_id bigint NOT NULL,
+ member_id bigint,
+ recipient_name varchar(255) NOT NULL,
+ email_snapshot varchar(255),
+ delivery_status varchar(32) NOT NULL DEFAULT 'pending',
+ delivered_at timestamptz,
+ last_attempt_at timestamptz,
+ attempt_count integer NOT NULL DEFAULT 0,
+ retryable boolean NOT NULL DEFAULT false,
+ error_code varchar(64),
+ transport_message_id varchar(255),
+ error_message text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_club_communication_recipients_thread
+ ON club_communication_recipients (thread_id, delivery_status);
+
+CREATE TABLE IF NOT EXISTS club_communication_delivery_logs (
+ id bigserial PRIMARY KEY,
+ club_id bigint NOT NULL,
+ thread_id bigint NOT NULL,
+ recipient_id bigint,
+ created_by_user_id bigint,
+ status varchar(32) NOT NULL DEFAULT 'failed',
+ attempt_no integer NOT NULL DEFAULT 1,
+ retryable boolean NOT NULL DEFAULT false,
+ error_code varchar(64),
+ error_message text,
+ transport_message_id varchar(255),
+ transport_response text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_thread
+ ON club_communication_delivery_logs (thread_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_recipient
+ ON club_communication_delivery_logs (recipient_id);
+
CREATE INDEX IF NOT EXISTS idx_club_requests_club_status
ON club_requests (club_id, status, request_type);
@@ -483,7 +588,11 @@ CREATE TABLE IF NOT EXISTS club_invoice_parties (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
party_type varchar(32) NOT NULL,
+ status varchar(32) NOT NULL DEFAULT 'active',
name varchar(255) NOT NULL,
+ contract_reference varchar(120),
+ valid_from date,
+ valid_to date,
contact_name varchar(255),
email varchar(255),
phone varchar(80),
@@ -501,6 +610,12 @@ CREATE TABLE IF NOT EXISTS club_invoice_parties (
CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_type
ON club_invoice_parties (club_id, party_type);
+CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_status
+ ON club_invoice_parties (club_id, status);
+CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_valid_from
+ ON club_invoice_parties (club_id, valid_from);
+CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_valid_to
+ ON club_invoice_parties (club_id, valid_to);
CREATE TABLE IF NOT EXISTS club_invoices (
id bigserial PRIMARY KEY,
@@ -530,6 +645,10 @@ CREATE TABLE IF NOT EXISTS club_invoices (
CREATE INDEX IF NOT EXISTS idx_club_invoices_club_direction_status
ON club_invoices (club_id, invoice_direction, status, due_on);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_club_invoices_number
+ ON club_invoices (club_id, invoice_number)
+ WHERE invoice_number IS NOT NULL;
+
CREATE TABLE IF NOT EXISTS club_invoice_items (
id bigserial PRIMARY KEY,
invoice_id bigint NOT NULL,
@@ -652,4 +771,83 @@ BEGIN
END
$$;
+ALTER TABLE IF EXISTS club_communication_recipients
+ ADD COLUMN IF NOT EXISTS last_attempt_at timestamptz,
+ ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS retryable boolean NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS error_code varchar(64),
+ ADD COLUMN IF NOT EXISTS transport_message_id varchar(255);
+
+CREATE TABLE IF NOT EXISTS club_communication_delivery_logs (
+ id bigserial PRIMARY KEY,
+ club_id bigint NOT NULL,
+ thread_id bigint NOT NULL,
+ recipient_id bigint,
+ created_by_user_id bigint,
+ status varchar(32) NOT NULL DEFAULT 'failed',
+ attempt_no integer NOT NULL DEFAULT 1,
+ retryable boolean NOT NULL DEFAULT false,
+ error_code varchar(64),
+ error_message text,
+ transport_message_id varchar(255),
+ transport_response text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_thread
+ ON club_communication_delivery_logs (thread_id, created_at DESC);
+
+CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_recipient
+ ON club_communication_delivery_logs (recipient_id);
+
+ALTER TABLE IF EXISTS club_distribution_groups
+ ADD COLUMN IF NOT EXISTS filter_definition jsonb;
+
+ALTER TABLE IF EXISTS club_communication_threads
+ ADD COLUMN IF NOT EXISTS recipient_filters jsonb;
+
+CREATE TABLE IF NOT EXISTS club_communication_templates (
+ id bigserial PRIMARY KEY,
+ club_id bigint NOT NULL,
+ name varchar(160) NOT NULL,
+ category varchar(64) NOT NULL DEFAULT 'general',
+ subject_template varchar(255),
+ body_template text,
+ variables_hint text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_club_communication_templates_club
+ ON club_communication_templates (club_id, category, name);
+
+CREATE TABLE IF NOT EXISTS club_account_transactions (
+ id bigserial PRIMARY KEY,
+ club_id bigint NOT NULL,
+ account_id bigint NOT NULL,
+ invoice_id bigint,
+ created_by_user_id bigint,
+ direction varchar(16) NOT NULL DEFAULT 'credit',
+ booking_type varchar(32) NOT NULL DEFAULT 'manual',
+ status varchar(32) NOT NULL DEFAULT 'booked',
+ booking_date date NOT NULL,
+ value_date date,
+ amount_cents bigint NOT NULL DEFAULT 0,
+ currency_code varchar(3) NOT NULL DEFAULT 'EUR',
+ reference varchar(255),
+ notes text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_club_account_transactions_club_date
+ ON club_account_transactions (club_id, booking_date DESC, status);
+
+CREATE INDEX IF NOT EXISTS idx_club_account_transactions_account_date
+ ON club_account_transactions (account_id, booking_date DESC);
+
+CREATE INDEX IF NOT EXISTS idx_club_account_transactions_invoice
+ ON club_account_transactions (invoice_id);
+
COMMIT;
diff --git a/frontend/src/components/DiaryParticipantsPanel.vue b/frontend/src/components/DiaryParticipantsPanel.vue
index 1bc3b224..8278a735 100644
--- a/frontend/src/components/DiaryParticipantsPanel.vue
+++ b/frontend/src/components/DiaryParticipantsPanel.vue
@@ -1,6 +1,9 @@
+
+
+
+
+ Bitte zuerst ein Konto auswählen.
+ Für dieses Konto gibt es noch keine Kontobewegungen.
+
+
+
+
+
+
{{ transaction.reference || 'Manuelle Buchung' }}
+
+ {{ displayTransactionDirection(transaction.direction) }}
+ {{ displayTransactionStatus(transaction.status) }}
+ {{ displayBookingType(transaction.bookingType) }}
+
+
+
+ {{ transaction.bookingDate || 'Kein Buchungsdatum' }}
+ {{ formatTransactionAmount(transaction) }}
+ Rechnung #{{ transaction.invoiceId }}
+ Forderung #{{ transaction.paymentClaimId }}
+
+
+ {{ displayPaymentClaimMember(transaction.paymentClaim.member) }}
+
+
{{ transaction.notes || 'Keine Notiz hinterlegt.' }}
+
+
+
+
+
+
+
+