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 @@ + + + + diff --git a/frontend/src/components/diary/DiaryOverviewPanels.vue b/frontend/src/components/diary/DiaryOverviewPanels.vue index 071424d4..74b9841a 100644 --- a/frontend/src/components/diary/DiaryOverviewPanels.vue +++ b/frontend/src/components/diary/DiaryOverviewPanels.vue @@ -28,10 +28,22 @@ {{ $t('diary.participants') }} {{ participantCount }} +
+ {{ $t('diary.excusedParticipants') }} + {{ excusedCount }} +
+
+ {{ $t('diary.availableParticipants') }} + {{ availableParticipantCount }} +
{{ $t('diary.trainingPlan') }} {{ trainingPlanCount }}
+
+ {{ $t('diary.activeMembers') }} + {{ activeMemberCount }} +
{{ $t('diary.freeActivities') }} {{ activitiesCount }} @@ -127,6 +139,9 @@ export default { diaryStatusText: { type: String, default: '' }, diaryTimeRangeLabel: { type: String, default: '' }, participantCount: { type: Number, default: 0 }, + excusedCount: { type: Number, default: 0 }, + activeMemberCount: { type: Number, default: 0 }, + availableParticipantCount: { type: Number, default: 0 }, trainingPlanCount: { type: Number, default: 0 }, activitiesCount: { type: Number, default: 0 }, trainingStart: { type: String, default: '' }, @@ -214,9 +229,9 @@ export default { .diary-workspace-stats { display: grid; - grid-template-columns: repeat(4, minmax(110px, 1fr)); + grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: 0.75rem; - width: min(560px, 100%); + width: min(840px, 100%); } .diary-stat-card { @@ -308,7 +323,7 @@ export default { } .diary-workspace-stats { - grid-template-columns: repeat(2, minmax(92px, 1fr)); + grid-template-columns: repeat(3, minmax(92px, 1fr)); gap: 0.5rem; width: 100%; } diff --git a/frontend/src/config/clubDataModels.js b/frontend/src/config/clubDataModels.js index 88ec7488..2124d05f 100644 --- a/frontend/src/config/clubDataModels.js +++ b/frontend/src/config/clubDataModels.js @@ -22,6 +22,7 @@ export const CLUB_DATA_MODELS = { 'billing_email', 'iban', 'bic', + 'fee_rules', 'is_archived', 'archived_at', 'created_at', @@ -116,8 +117,8 @@ export const CLUB_DATA_MODELS = { ], }, event: { - table: 'club_events', - purpose: 'Vereinstermine für Training, Spiele und Vereinsveranstaltungen.', + table: 'calendar_events', + purpose: 'Vereinstermine für Training, Spiele und Vereinsveranstaltungen mit Fristen und Zuständigkeiten.', fields: [ 'id', 'club_id', @@ -126,10 +127,11 @@ export const CLUB_DATA_MODELS = { 'title', 'description', 'location', - 'starts_at', - 'ends_at', + 'start_date', + 'end_date', 'registration_deadline', 'organizer_user_id', + 'notes', 'created_at', 'updated_at', 'archived_at', @@ -137,7 +139,7 @@ export const CLUB_DATA_MODELS = { }, document: { table: 'club_documents', - purpose: 'Dokumentenstamm für Satzung, Protokolle, Formulare und Vereinsdokumente.', + purpose: 'Dokumentenstamm für Satzung, Protokolle, Formulare und Vereinsdokumente mit Versionen, Sichtbarkeit und Belegbezug.', fields: [ 'id', 'club_id', @@ -180,23 +182,30 @@ export const CLUB_DATA_MODELS = { ], }, sponsor: { - table: 'club_sponsors', - purpose: 'Sponsorenbeziehung mit Ansprechpartnern, Verträgen und Zahlungsbezug.', + table: 'club_invoice_parties', + purpose: 'Sponsorenbeziehung innerhalb der Rechnungsparteien mit Ansprechpartnern, Verträgen und Laufzeiten.', fields: [ 'id', 'club_id', - 'name', + 'party_type', 'status', - 'website', + 'name', + 'contract_reference', + 'valid_from', + 'valid_to', + 'contact_name', 'email', 'phone', 'street', 'postal_code', 'city', + 'country_code', + 'iban', + 'bic', + 'tax_identifier', 'notes', 'created_at', 'updated_at', - 'archived_at', ], }, feeRule: { @@ -278,12 +287,14 @@ export const CLUB_DATA_MODELS = { 'status', 'due_on', 'amount_cents', + 'paid_amount_cents', 'currency_code', 'reminder_level', 'last_reminder_at', 'created_at', 'updated_at', 'settled_at', + 'last_paid_at', 'archived_at', ], }, diff --git a/frontend/src/config/clubWorkspace.js b/frontend/src/config/clubWorkspace.js index 68669891..32875b9d 100644 --- a/frontend/src/config/clubWorkspace.js +++ b/frontend/src/config/clubWorkspace.js @@ -85,11 +85,11 @@ export const CLUB_DASHBOARD_SECTIONS = [ ]; export const CLUB_DASHBOARD_QUICK_LINKS = [ - { to: '/club-tasks', label: 'Aufgaben steuern', icon: '✅' }, - { to: '/club-requests', label: 'Anfragen bearbeiten', icon: '📥' }, - { to: '/members', label: 'Mitglieder öffnen', icon: '👥' }, - { to: '/club-payments', label: 'Zahlungen prüfen', icon: '💶' }, - { to: '/club-documents', label: 'Dokumente verwalten', icon: '🗂️' }, + { to: '/club-tasks', label: 'Aufgaben steuern', icon: '✅', permission: ['tasks', 'read'] }, + { to: '/club-requests', label: 'Anfragen bearbeiten', icon: '📥', permission: ['requests', 'read'] }, + { to: '/members', label: 'Mitglieder öffnen', icon: '👥', permission: ['members', 'read'] }, + { to: '/club-payments', label: 'Zahlungen prüfen', icon: '💶', permission: ['finance_accounts', 'read'] }, + { to: '/club-documents', label: 'Dokumente verwalten', icon: '🗂️', permission: ['settings', 'read'] }, ]; export const CLUB_MENU_SECTIONS = [ @@ -98,9 +98,9 @@ export const CLUB_MENU_SECTIONS = [ title: 'Hauptmenü', items: [ { to: '/', icon: '🏠', label: 'Dashboard' }, - { to: '/club-requests', icon: '📥', label: 'Anfragen', permission: ['approvals', 'read'] }, + { to: '/club-requests', icon: '📥', label: 'Anfragen', permission: ['requests', 'read'] }, { to: '/members', icon: '👥', label: 'Mitglieder', permission: ['members', 'read'] }, - { to: '/club-communication', icon: '💬', label: 'Kommunikation', permission: ['members', 'read'] }, + { to: '/club-communication', icon: '💬', label: 'Kommunikation', permission: ['communication', 'read'] }, { to: '/calendar', icon: '📆', label: 'Termine', permission: ['schedule', 'read'] }, { to: '/club-documents', icon: '🗂️', label: 'Dokumente', permission: ['settings', 'read'] }, ], @@ -109,7 +109,7 @@ export const CLUB_MENU_SECTIONS = [ id: 'organisation', title: 'Organisation', items: [ - { to: '/club-tasks', icon: '✅', label: 'Aufgaben', permission: ['approvals', 'read'] }, + { to: '/club-tasks', icon: '✅', label: 'Aufgaben', permission: ['tasks', 'read'] }, { to: '/team-management', icon: '🧩', label: 'Mannschaften', permission: ['teams', 'read'] }, { to: '/club-events', icon: '🎪', label: 'Veranstaltungen', permission: ['schedule', 'read'] }, { to: '/club-sponsors', icon: '🤝', label: 'Sponsoren', permission: ['settings', 'read'] }, @@ -119,10 +119,10 @@ export const CLUB_MENU_SECTIONS = [ id: 'finance', title: 'Finanzen', items: [ - { to: '/club-fees', icon: '💳', label: 'Beiträge', permission: ['members', 'write'] }, - { to: '/club-payments', icon: '💶', label: 'Zahlungen', permission: ['members', 'write'] }, - { to: '/club-invoices', icon: '🧾', label: 'Rechnungen', permission: ['members', 'write'] }, - { to: '/club-accounts', icon: '🏦', label: 'Konten', permission: ['members', 'write'] }, + { to: '/club-fees', icon: '💳', label: 'Beiträge', permission: ['finance_invoices', 'write'] }, + { to: '/club-payments', icon: '💶', label: 'Zahlungen', permission: ['finance_accounts', 'write'] }, + { to: '/club-invoices', icon: '🧾', label: 'Rechnungen', permission: ['finance_invoices', 'write'] }, + { to: '/club-accounts', icon: '🏦', label: 'Konten', permission: ['finance_accounts', 'write'] }, ], }, { @@ -131,7 +131,7 @@ export const CLUB_MENU_SECTIONS = [ items: [ { to: '/club-users', icon: '👤', label: 'Benutzer', permission: ['permissions', 'read'] }, { to: '/club-roles', icon: '🛡️', label: 'Rollen', permission: ['permissions', 'read'] }, - { to: '/club-history', icon: '🕘', label: 'Historie', permission: ['members', 'read'] }, + { to: '/club-history', icon: '🕘', label: 'Historie', permission: ['history', 'read'] }, { to: '/club-settings', icon: '⚙️', label: 'Einstellungen', capability: 'admin' }, ], }, @@ -141,7 +141,7 @@ export const CLUB_MENU_SECTIONS = [ items: [ { to: '/club-statistics', icon: '📊', label: 'Statistiken', permission: ['statistics', 'read'] }, { to: '/club-reports', icon: '📑', label: 'Berichte', permission: ['statistics', 'read'] }, - { to: '/club-archive', icon: '🗄️', label: 'Archiv', permission: ['settings', 'read'] }, + { to: '/club-archive', icon: '🗄️', label: 'Archiv', permission: ['archive', 'read'] }, ], }, ]; diff --git a/frontend/src/i18n/locales/de-CH.json b/frontend/src/i18n/locales/de-CH.json index 77fb411e..d0425263 100644 --- a/frontend/src/i18n/locales/de-CH.json +++ b/frontend/src/i18n/locales/de-CH.json @@ -903,6 +903,9 @@ "durationExampleShort": "z.B. 2x7", "showImage": "Bild/Zeichnung anzeigen", "participants": "Teilnehmer", + "excusedParticipants": "Entschuldigt", + "availableParticipants": "Anwesend möglich", + "activeMembers": "Aktive Mitglieder", "searchParticipants": "Teilnehmer suchen", "filterAll": "Alle", "filterPresent": "Anwesend", diff --git a/frontend/src/i18n/locales/de-extended.json b/frontend/src/i18n/locales/de-extended.json index 7dfdd638..79540ecf 100644 --- a/frontend/src/i18n/locales/de-extended.json +++ b/frontend/src/i18n/locales/de-extended.json @@ -652,6 +652,9 @@ "durationExampleShort": "z.B. 2x7", "showImage": "Bild/Zeichnung anzeigen", "participants": "Teilnehmer", + "excusedParticipants": "Entschuldigt", + "availableParticipants": "Anwesend möglich", + "activeMembers": "Aktive Mitglieder", "searchParticipants": "Teilnehmer suchen", "filterAll": "Alle", "filterPresent": "Anwesend", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 34c9d5b8..976eaf69 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -741,6 +741,9 @@ "durationExampleShort": "z.B. 2x7", "showImage": "Bild/Zeichnung anzeigen", "participants": "Teilnehmer", + "excusedParticipants": "Entschuldigt", + "availableParticipants": "Anwesend möglich", + "activeMembers": "Aktive Mitglieder", "searchParticipants": "Teilnehmer suchen", "filterAll": "Alle", "filterPresent": "Anwesend", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index 69dd96a3..fe3dfea0 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -714,6 +714,9 @@ "durationExampleShort": "例如:2x7", "showImage": "显示图片/图示", "participants": "参与者", + "excusedParticipants": "已请假", + "availableParticipants": "可到场", + "activeMembers": "活跃成员", "searchParticipants": "搜索参与者", "filterAll": "全部", "filterPresent": "出席", diff --git a/frontend/src/router.js b/frontend/src/router.js index 7e1ccb4d..0b810007 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -43,6 +43,8 @@ const ClubStatisticsView = () => import('./views/ClubStatisticsView.vue'); const ClubArchiveView = () => import('./views/ClubArchiveView.vue'); const ClubAccountsView = () => import('./views/ClubAccountsView.vue'); const ClubInvoicesView = () => import('./views/ClubInvoicesView.vue'); +const ClubCommunicationView = () => import('./views/ClubCommunicationView.vue'); +const ClubOperationsWorkspaceView = () => import('./views/ClubOperationsWorkspaceView.vue'); const ClubConceptModuleView = () => import('./views/ClubConceptModuleView.vue'); const Impressum = () => import('./views/Impressum.vue'); const Datenschutz = () => import('./views/Datenschutz.vue'); @@ -142,6 +144,13 @@ const conceptRoutes = CLUB_CONCEPT_ROUTES .filter((route) => route.path !== '/club-archive') .filter((route) => route.path !== '/club-accounts') .filter((route) => route.path !== '/club-invoices') + .filter((route) => route.path !== '/club-communication') + .filter((route) => route.path !== '/club-documents') + .filter((route) => route.path !== '/club-events') + .filter((route) => route.path !== '/club-sponsors') + .filter((route) => route.path !== '/club-fees') + .filter((route) => route.path !== '/club-payments') + .filter((route) => route.path !== '/club-reports') .map((route) => ({ path: route.path, name: route.name, @@ -185,13 +194,20 @@ const routes = [ { path: '/personal-settings', name: 'personal-settings', component: PersonalSettings, meta: withMeta({ products: allProducts }) }, { path: '/orders', name: 'orders', component: OrdersView, meta: withMeta({ products: allProducts }) }, { path: '/billing', name: 'billing', component: BillingView, meta: withMeta({ products: trainerOnly }) }, - { path: '/club-requests', name: 'club-requests', component: ClubRequestsView, meta: withMeta({ products: clubOnly }) }, - { path: '/club-tasks', name: 'club-tasks', component: ClubTasksView, meta: withMeta({ products: clubOnly }) }, - { path: '/club-history', name: 'club-history', component: ClubHistoryView, meta: withMeta({ products: clubOnly, permission: ['members', 'read'] }) }, + { path: '/club-requests', name: 'club-requests', component: ClubRequestsView, meta: withMeta({ products: clubOnly, permission: ['requests', 'read'] }) }, + { path: '/club-tasks', name: 'club-tasks', component: ClubTasksView, meta: withMeta({ products: clubOnly, permission: ['tasks', 'read'] }) }, + { path: '/club-history', name: 'club-history', component: ClubHistoryView, meta: withMeta({ products: clubOnly, permission: ['history', 'read'] }) }, { path: '/club-statistics', name: 'club-statistics', component: ClubStatisticsView, meta: withMeta({ products: clubOnly, permission: ['statistics', 'read'] }) }, - { path: '/club-archive', name: 'club-archive', component: ClubArchiveView, meta: withMeta({ products: clubOnly, permission: ['settings', 'read'] }) }, - { path: '/club-accounts', name: 'club-accounts', component: ClubAccountsView, meta: withMeta({ products: clubOnly, permission: ['members', 'read'] }) }, - { path: '/club-invoices', name: 'club-invoices', component: ClubInvoicesView, meta: withMeta({ products: clubOnly, permission: ['members', 'read'] }) }, + { path: '/club-archive', name: 'club-archive', component: ClubArchiveView, meta: withMeta({ products: clubOnly, permission: ['archive', 'read'] }) }, + { path: '/club-accounts', name: 'club-accounts', component: ClubAccountsView, meta: withMeta({ products: clubOnly, permission: ['finance_accounts', 'read'] }) }, + { path: '/club-invoices', name: 'club-invoices', component: ClubInvoicesView, meta: withMeta({ products: clubOnly, permission: ['finance_invoices', 'read'] }) }, + { path: '/club-communication', name: 'club-communication', component: ClubCommunicationView, meta: withMeta({ products: clubOnly, permission: ['communication', 'read'] }) }, + { path: '/club-documents', name: 'club-documents', component: ClubOperationsWorkspaceView, props: { moduleKey: 'documents' }, meta: withMeta({ products: clubOnly, permission: ['settings', 'read'] }) }, + { path: '/club-events', name: 'club-events', component: ClubOperationsWorkspaceView, props: { moduleKey: 'events' }, meta: withMeta({ products: clubOnly, permission: ['schedule', 'read'] }) }, + { path: '/club-sponsors', name: 'club-sponsors', component: ClubOperationsWorkspaceView, props: { moduleKey: 'sponsors' }, meta: withMeta({ products: clubOnly, permission: ['settings', 'read'] }) }, + { path: '/club-fees', name: 'club-fees', component: ClubOperationsWorkspaceView, props: { moduleKey: 'fees' }, meta: withMeta({ products: clubOnly, permission: ['finance_invoices', 'write'] }) }, + { path: '/club-payments', name: 'club-payments', component: ClubOperationsWorkspaceView, props: { moduleKey: 'payments' }, meta: withMeta({ products: clubOnly, permission: ['finance_accounts', 'read'] }) }, + { path: '/club-reports', name: 'club-reports', component: ClubOperationsWorkspaceView, props: { moduleKey: 'reports' }, meta: withMeta({ products: clubOnly, permission: ['statistics', 'read'] }) }, ...conceptRoutes, { path: '/impressum', name: 'impressum', component: Impressum, meta: withMeta({ public: true, products: allProducts }) }, { path: '/datenschutz', name: 'datenschutz', component: Datenschutz, meta: withMeta({ public: true, products: allProducts }) }, diff --git a/frontend/src/utils/reportExport.js b/frontend/src/utils/reportExport.js new file mode 100644 index 00000000..94e33e09 --- /dev/null +++ b/frontend/src/utils/reportExport.js @@ -0,0 +1,117 @@ +import jsPDF from 'jspdf'; +import autoTable from 'jspdf-autotable'; + +function downloadBlob(blob, filename) { + const url = window.URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.style.display = 'none'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.URL.revokeObjectURL(url); +} + +function escapeCsvCell(value) { + return `"${String(value ?? '').replace(/"/g, '""').replace(/\r?\n/g, ' ')}"`; +} + +export function downloadCsvReport(filename, sections = []) { + const lines = []; + + sections.forEach((section, sectionIndex) => { + if (sectionIndex > 0) { + lines.push(''); + } + if (section.title) { + lines.push(escapeCsvCell(section.title)); + } + if (Array.isArray(section.headers) && section.headers.length > 0) { + lines.push(section.headers.map((header) => escapeCsvCell(header)).join(',')); + } + (Array.isArray(section.rows) ? section.rows : []).forEach((row) => { + lines.push(row.map((value) => escapeCsvCell(value)).join(',')); + }); + }); + + const blob = new Blob([`${lines.join('\n')}\n`], { type: 'text/csv;charset=utf-8;' }); + downloadBlob(blob, filename); +} + +export function exportReportPdf({ + filename, + title, + subtitle = '', + sections = [], +}) { + const doc = new jsPDF({ unit: 'pt', format: 'a4' }); + const pageWidth = doc.internal.pageSize.getWidth(); + const marginX = 40; + let cursorY = 48; + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(18); + doc.text(title, marginX, cursorY); + cursorY += 18; + + if (subtitle) { + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10.5); + doc.setTextColor(90, 90, 90); + const wrapped = doc.splitTextToSize(subtitle, pageWidth - marginX * 2); + doc.text(wrapped, marginX, cursorY); + cursorY += wrapped.length * 12 + 4; + doc.setTextColor(0, 0, 0); + } + + doc.setFontSize(9); + doc.setFont('helvetica', 'normal'); + doc.text(`Erstellt am ${new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date())}`, marginX, cursorY); + cursorY += 18; + + sections.forEach((section) => { + if (cursorY > doc.internal.pageSize.getHeight() - 120) { + doc.addPage(); + cursorY = 42; + } + + if (section.title) { + doc.setFont('helvetica', 'bold'); + doc.setFontSize(12); + doc.text(section.title, marginX, cursorY); + cursorY += 14; + } + + if (Array.isArray(section.lines)) { + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + section.lines.forEach((line) => { + const wrapped = doc.splitTextToSize(`- ${line}`, pageWidth - marginX * 2); + doc.text(wrapped, marginX, cursorY); + cursorY += wrapped.length * 11; + }); + cursorY += 6; + } + + if (section.table && Array.isArray(section.table.rows) && section.table.rows.length > 0) { + autoTable(doc, { + startY: cursorY, + head: [section.table.headers || []], + body: section.table.rows, + margin: { left: marginX, right: marginX }, + styles: { fontSize: 9, cellPadding: 4 }, + headStyles: { fillColor: [24, 70, 54] }, + theme: 'grid', + }); + cursorY = (doc.lastAutoTable?.finalY || cursorY) + 16; + } else if (section.table) { + doc.setFont('helvetica', 'italic'); + doc.setFontSize(10); + doc.text('Keine Einträge vorhanden.', marginX, cursorY); + cursorY += 14; + } + }); + + doc.save(filename); +} diff --git a/frontend/src/utils/richTextDocumentExport.js b/frontend/src/utils/richTextDocumentExport.js new file mode 100644 index 00000000..58b3a2d0 --- /dev/null +++ b/frontend/src/utils/richTextDocumentExport.js @@ -0,0 +1,429 @@ +import jsPDF from 'jspdf'; +import { + AlignmentType, + Document, + HeadingLevel, + Paragraph, + TextRun, + Packer, +} from 'docx'; +import JSZip from 'jszip'; + +const BLOCK_TAGS = new Set(['P', 'DIV', 'BLOCKQUOTE', 'UL', 'OL', 'LI', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']); +const INLINE_TAGS = new Set(['STRONG', 'B', 'EM', 'I', 'U', 'A', 'SPAN']); +const ALLOWED_TAGS = new Set(['BR', ...BLOCK_TAGS, ...INLINE_TAGS]); + +function getDomParser() { + return typeof DOMParser !== 'undefined' ? new DOMParser() : null; +} + +export function sanitizeRichTextHtml(input = '') { + const parser = getDomParser(); + if (!parser) return String(input || ''); + + const doc = parser.parseFromString(`
${String(input || '')}
`, 'text/html'); + const root = doc.body.firstElementChild || doc.body; + + const cleanseNode = (node) => { + if (node.nodeType === Node.TEXT_NODE) { + return; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + node.remove(); + return; + } + + const tagName = node.tagName.toUpperCase(); + if (!ALLOWED_TAGS.has(tagName)) { + const parent = node.parentNode; + const children = Array.from(node.childNodes); + children.forEach((child) => parent.insertBefore(child, node)); + node.remove(); + children.forEach(cleanseNode); + return; + } + + [...node.attributes].forEach((attribute) => { + const name = attribute.name.toLowerCase(); + if (tagName === 'A' && name === 'href') { + const value = String(attribute.value || '').trim(); + const allowed = /^(https?:|mailto:|tel:|#|\/)/i.test(value); + if (!allowed) { + node.removeAttribute(attribute.name); + } + return; + } + node.removeAttribute(attribute.name); + }); + + if (tagName === 'A') { + const href = node.getAttribute('href'); + if (href) { + node.setAttribute('rel', 'noreferrer noopener'); + node.setAttribute('target', '_blank'); + } + } + + Array.from(node.childNodes).forEach(cleanseNode); + }; + + Array.from(root.childNodes).forEach(cleanseNode); + return root.innerHTML; +} + +export function stripRichTextToText(input = '') { + const parser = getDomParser(); + if (!parser) { + return String(input || '') + .replace(//gi, '\n') + .replace(/<\/p>\s*

/gi, '\n\n') + .replace(/<[^>]+>/g, '') + .trim(); + } + + const doc = parser.parseFromString(`

${sanitizeRichTextHtml(input)}
`, 'text/html'); + const root = doc.body.firstElementChild || doc.body; + + const collect = (node) => { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || ''; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return ''; + } + + const tagName = node.tagName.toUpperCase(); + if (tagName === 'BR') return '\n'; + if (tagName === 'LI') return `- ${Array.from(node.childNodes).map(collect).join('').trim()}\n`; + const text = Array.from(node.childNodes).map(collect).join(''); + if (BLOCK_TAGS.has(tagName)) { + return `\n${text.trim()}\n`; + } + return text; + }; + + return collect(root) + .replace(/\n{3,}/g, '\n\n') + .replace(/[ \t]+\n/g, '\n') + .trim(); +} + +function parseInlineRuns(node, style = {}) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent || ''; + return text ? [{ text, ...style }] : []; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return []; + } + + const tagName = node.tagName.toUpperCase(); + if (tagName === 'BR') { + return [{ text: '\n', ...style }]; + } + + const nextStyle = { ...style }; + if (tagName === 'STRONG' || tagName === 'B') nextStyle.bold = true; + if (tagName === 'EM' || tagName === 'I') nextStyle.italics = true; + if (tagName === 'U') nextStyle.underline = {}; + if (tagName === 'A') { + nextStyle.color = '0563C1'; + nextStyle.underline = {}; + } + + return Array.from(node.childNodes).flatMap((child) => parseInlineRuns(child, nextStyle)); +} + +function htmlToDocxParagraphs(input = '') { + const parser = getDomParser(); + if (!parser) { + return [new Paragraph(String(input || '').trim())]; + } + + const doc = parser.parseFromString(`
${sanitizeRichTextHtml(input)}
`, 'text/html'); + const root = doc.body.firstElementChild || doc.body; + const paragraphs = []; + + const pushParagraph = (node, options = {}) => { + const runs = Array.from(node.childNodes).flatMap((child) => parseInlineRuns(child)); + const textRuns = runs.length ? runs : [{ text: node.textContent || '' }]; + paragraphs.push(new Paragraph({ + children: textRuns.map((run) => new TextRun(run)), + spacing: { after: 180 }, + bullet: options.bullet ? { level: 0 } : undefined, + heading: options.heading || undefined, + alignment: options.alignment || AlignmentType.LEFT, + })); + }; + + const walk = (node) => { + if (!node) return; + if (node.nodeType === Node.TEXT_NODE) { + const text = (node.textContent || '').trim(); + if (text) { + paragraphs.push(new Paragraph({ children: [new TextRun(text)], spacing: { after: 180 } })); + } + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + + const tagName = node.tagName.toUpperCase(); + if (tagName === 'UL' || tagName === 'OL') { + Array.from(node.children).forEach((child, index) => { + if (child.tagName?.toUpperCase() !== 'LI') return; + const runs = Array.from(child.childNodes).flatMap((grandChild) => parseInlineRuns(grandChild)); + const textRuns = runs.length ? runs : [{ text: child.textContent || '' }]; + paragraphs.push(new Paragraph({ + children: textRuns.map((run) => new TextRun(run)), + bullet: tagName === 'UL' ? { level: 0 } : undefined, + numbering: tagName === 'OL' ? { reference: 'number-list', level: 0 } : undefined, + spacing: { after: 120 }, + })); + }); + return; + } + + if (tagName === 'H1' || tagName === 'H2' || tagName === 'H3' || tagName === 'H4' || tagName === 'H5' || tagName === 'H6') { + const headingMap = { + H1: HeadingLevel.HEADING_1, + H2: HeadingLevel.HEADING_2, + H3: HeadingLevel.HEADING_3, + H4: HeadingLevel.HEADING_4, + H5: HeadingLevel.HEADING_5, + H6: HeadingLevel.HEADING_6, + }; + pushParagraph(node, { heading: headingMap[tagName] }); + return; + } + + if (tagName === 'BLOCKQUOTE') { + pushParagraph(node); + return; + } + + if (tagName === 'LI') { + pushParagraph(node, { bullet: true }); + return; + } + + if (tagName === 'P' || tagName === 'DIV') { + pushParagraph(node); + return; + } + + Array.from(node.children).forEach(walk); + }; + + Array.from(root.children).forEach(walk); + if (paragraphs.length === 0) { + paragraphs.push(new Paragraph({ children: [new TextRun(stripRichTextToText(input) || '')], spacing: { after: 180 } })); + } + return paragraphs; +} + +function escapeXml(value = '') { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function htmlToOdtParagraphs(input = '') { + const parser = getDomParser(); + if (!parser) { + return [escapeXml(String(input || '').trim())]; + } + + const doc = parser.parseFromString(`
${sanitizeRichTextHtml(input)}
`, 'text/html'); + const root = doc.body.firstElementChild || doc.body; + const paragraphs = []; + + const inlineText = (node) => { + if (node.nodeType === Node.TEXT_NODE) { + return escapeXml(node.textContent || ''); + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return ''; + } + const tagName = node.tagName.toUpperCase(); + if (tagName === 'BR') return ''; + const text = Array.from(node.childNodes).map(inlineText).join(''); + if (tagName === 'LI') { + return `• ${text}`; + } + return text; + }; + + const walk = (node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = escapeXml((node.textContent || '').trim()); + if (text) paragraphs.push(`${text}`); + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) return; + + const tagName = node.tagName.toUpperCase(); + if (tagName === 'UL' || tagName === 'OL') { + Array.from(node.children).forEach((child, index) => { + if (child.tagName?.toUpperCase() !== 'LI') return; + const prefix = tagName === 'OL' ? `${index + 1}. ` : '• '; + paragraphs.push(`${prefix}${Array.from(child.childNodes).map(inlineText).join('')}`); + }); + return; + } + + if (tagName === 'H1' || tagName === 'H2' || tagName === 'H3' || tagName === 'H4' || tagName === 'H5' || tagName === 'H6') { + paragraphs.push(`${Array.from(node.childNodes).map(inlineText).join('')}`); + return; + } + + if (tagName === 'P' || tagName === 'DIV' || tagName === 'BLOCKQUOTE' || tagName === 'LI') { + paragraphs.push(`${Array.from(node.childNodes).map(inlineText).join('')}`); + return; + } + + Array.from(node.children).forEach(walk); + }; + + Array.from(root.children).forEach(walk); + if (paragraphs.length === 0) { + paragraphs.push(`${escapeXml(stripRichTextToText(input) || '')}`); + } + return paragraphs; +} + +async function downloadBlob(blob, filename) { + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); +} + +export async function exportRichTextPdf({ title, subject = '', bodyHtml = '', filename = 'schreiben.pdf' }) { + const html = sanitizeRichTextHtml(bodyHtml); + const doc = new jsPDF({ unit: 'pt', format: 'a4' }); + const container = document.createElement('div'); + container.style.width = '540pt'; + container.style.padding = '24pt'; + container.innerHTML = ` +
+

${escapeXml(title || 'Schreiben')}

+ ${subject ? `

Betreff: ${escapeXml(subject)}

` : ''} +
${html}
+
+ `; + + return new Promise((resolve, reject) => { + doc.html(container, { + callback: async (pdf) => { + try { + await downloadBlob(pdf.output('blob'), filename); + resolve(); + } catch (error) { + reject(error); + } + }, + x: 24, + y: 24, + width: 540, + windowWidth: 960, + }); + }); +} + +export async function exportRichTextDocx({ title, subject = '', bodyHtml = '', filename = 'schreiben.docx' }) { + const paragraphs = [ + new Paragraph({ + children: [new TextRun({ text: title || 'Schreiben', bold: true, size: 28 })], + spacing: { after: 180 }, + }), + ]; + + if (subject) { + paragraphs.push(new Paragraph({ + children: [new TextRun({ text: `Betreff: ${subject}`, bold: true })], + spacing: { after: 180 }, + })); + } + + paragraphs.push(...htmlToDocxParagraphs(bodyHtml)); + + const document = new Document({ + numbering: { + config: [{ + reference: 'number-list', + levels: [{ + level: 0, + format: 'decimal', + text: '%1.', + alignment: AlignmentType.START, + }], + }], + }, + sections: [{ + children: paragraphs, + }], + }); + + const blob = await Packer.toBlob(document); + await downloadBlob(blob, filename); +} + +export async function exportRichTextOdt({ title, subject = '', bodyHtml = '', filename = 'schreiben.odt' }) { + const zip = new JSZip(); + zip.file('mimetype', 'application/vnd.oasis.opendocument.text', { compression: 'STORE' }); + zip.file('content.xml', ` + + + + ${escapeXml(title || 'Schreiben')} + ${subject ? `Betreff: ${escapeXml(subject)}` : ''} + ${htmlToOdtParagraphs(bodyHtml).join('\n')} + + +`); + zip.file('styles.xml', ` + + +`); + zip.file('meta.xml', ` + + +`); + zip.folder('META-INF').file('manifest.xml', ` + + + + + +`); + + const blob = await zip.generateAsync({ type: 'blob', mimeType: 'application/vnd.oasis.opendocument.text' }); + await downloadBlob(blob, filename); +} + +export function exportRichTextPlainText({ title, subject = '', bodyHtml = '', filename = 'schreiben.txt' }) { + const lines = [ + title || 'Schreiben', + subject ? `Betreff: ${subject}` : '', + '', + stripRichTextToText(bodyHtml), + ].filter(Boolean); + const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' }); + return downloadBlob(blob, filename); +} diff --git a/frontend/src/views/CalendarView.vue b/frontend/src/views/CalendarView.vue index 38f0eba2..7bf406a4 100644 --- a/frontend/src/views/CalendarView.vue +++ b/frontend/src/views/CalendarView.vue @@ -712,6 +712,11 @@ export default { return (response.data || []).map(event => { const date = this.parseDate(event.startDate); const endDate = this.parseDate(event.endDate || event.startDate); + const subtitleParts = [ + event.category || this.$t('calendar.customEvent.subtitleFallback'), + event.status || '', + event.registrationDeadline ? `Frist ${this.formatDate(event.registrationDeadline)}` : '', + ].filter(Boolean); return { id: `custom-event-${event.id}`, customEventId: event.id, @@ -721,7 +726,7 @@ export default { startsAt: this.combineDateTime(date), time: '', title: event.title, - subtitle: event.category || this.$t('calendar.customEvent.subtitleFallback'), + subtitle: subtitleParts.join(' · '), }; }); }, @@ -734,6 +739,8 @@ export default { startDate: this.customEventForm.startDate, endDate: this.customEventForm.endDate || this.customEventForm.startDate, category: this.customEventForm.category || null, + eventType: 'club_event', + status: 'planning', }); this.ensureSuccess(response, this.$t('calendar.sources.customEvents')); this.customEventForm = { title: '', startDate: '', endDate: '', category: '' }; diff --git a/frontend/src/views/ClubAccountsView.vue b/frontend/src/views/ClubAccountsView.vue index 0c7b37cb..7ab1182f 100644 --- a/frontend/src/views/ClubAccountsView.vue +++ b/frontend/src/views/ClubAccountsView.vue @@ -11,6 +11,10 @@ +
+ Lesemodus aktiv. Konten und Kontobewegungen können angezeigt, aber nicht bearbeitet werden. +
+

Kein Verein ausgewählt

Bitte zuerst einen Verein auswählen, um Vereinskonten zu verwalten.

@@ -109,6 +113,52 @@
+ +
+
+

Kontobewegungen

+ +
+ +

Bitte zuerst ein Konto auswählen.

+

Für dieses Konto gibt es noch keine Kontobewegungen.

+ +
+ +
+