Updates, overview extended, club view implemented

This commit is contained in:
Torsten Schulz (local)
2026-06-23 16:17:07 +02:00
parent dd91c07694
commit 90e6c2f9f6
82 changed files with 11226 additions and 201 deletions

View File

@@ -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();

View File

@@ -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'],
],
});
}

View File

@@ -0,0 +1,828 @@
import sequelize from '../database.js';
import {
ClubCommunicationThread,
ClubCommunicationMessage,
ClubCommunicationRecipient,
ClubCommunicationDeliveryLog,
ClubCommunicationTemplate,
ClubDistributionGroup,
ClubDistributionGroupMember,
ClubSepaMandate,
Member,
MemberContact,
User,
} from '../models/index.js';
import { sendClubCommunicationEmail } from './emailService.js';
const THREAD_TYPES = new Set(['direct', 'group', 'broadcast']);
const THREAD_STATUSES = new Set(['draft', 'scheduled', 'sent', 'archived']);
const MESSAGE_TYPES = new Set(['message', 'note']);
const DIRECTIONS = new Set(['outbound', 'internal', 'inbound']);
const GROUP_TYPES = new Set(['manual', 'training_group', 'team', 'custom']);
function trimText(value, maxLength = null) {
const normalized = String(value || '').trim();
if (!normalized) return null;
return maxLength ? normalized.slice(0, maxLength) : normalized;
}
function normalizeDate(value) {
const normalized = String(value || '').trim();
return normalized || null;
}
function normalizeId(value) {
const numeric = Number(value);
return Number.isInteger(numeric) && numeric > 0 ? numeric : null;
}
function normalizeThreadPayload(payload = {}) {
return {
threadType: THREAD_TYPES.has(payload.threadType) ? payload.threadType : 'direct',
subject: trimText(payload.subject, 255),
status: THREAD_STATUSES.has(payload.status) ? payload.status : 'draft',
recipientMemberId: normalizeId(payload.recipientMemberId),
distributionGroupId: normalizeId(payload.distributionGroupId),
scheduledAt: normalizeDate(payload.scheduledAt),
recipientFilters: normalizeRecipientFilters(payload.recipientFilters),
};
}
function normalizeGroupPayload(payload = {}) {
const memberIds = Array.isArray(payload.memberIds)
? [...new Set(payload.memberIds.map(normalizeId).filter(Boolean))]
: [];
return {
name: trimText(payload.name, 255),
description: trimText(payload.description),
groupType: GROUP_TYPES.has(payload.groupType) ? payload.groupType : 'custom',
memberIds,
filterDefinition: normalizeRecipientFilters(payload.filterDefinition),
};
}
function normalizeMessagePayload(payload = {}) {
return {
body: trimText(payload.body),
messageType: MESSAGE_TYPES.has(payload.messageType) ? payload.messageType : 'message',
direction: DIRECTIONS.has(payload.direction) ? payload.direction : 'outbound',
};
}
function normalizeRecipientFilters(payload = {}) {
return {
activeOnly: payload?.activeOnly !== false,
onlyWithEmail: Boolean(payload?.onlyWithEmail),
missingEmail: Boolean(payload?.missingEmail),
requiresSepaMandate: Boolean(payload?.requiresSepaMandate),
missingSepaMandate: Boolean(payload?.missingSepaMandate),
testMembersOnly: Boolean(payload?.testMembersOnly),
};
}
function normalizeTemplatePayload(payload = {}) {
return {
name: trimText(payload.name, 160),
category: trimText(payload.category, 64) || 'general',
subjectTemplate: trimText(payload.subjectTemplate, 255),
bodyTemplate: trimText(payload.bodyTemplate),
variablesHint: trimText(payload.variablesHint),
};
}
function ensureThreadTargets(payload) {
if (!payload.subject) {
const error = new Error('Betreff ist erforderlich.');
error.status = 400;
throw error;
}
if (payload.threadType === 'direct' && !payload.recipientMemberId) {
const error = new Error('Für Einzelnachrichten muss ein Mitglied ausgewählt werden.');
error.status = 400;
throw error;
}
if (payload.threadType === 'group' && !payload.distributionGroupId) {
const error = new Error('Für Gruppen-Nachrichten muss eine Verteilergruppe ausgewählt werden.');
error.status = 400;
throw error;
}
}
async function ensureMemberBelongsToClub(clubId, memberId) {
if (!memberId) return;
const member = await Member.findOne({ where: { id: memberId, clubId } });
if (!member) {
const error = new Error('Mitglied wurde nicht gefunden.');
error.status = 404;
throw error;
}
}
async function ensureGroupBelongsToClub(clubId, groupId) {
if (!groupId) return null;
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
if (!group) {
const error = new Error('Verteilergruppe wurde nicht gefunden.');
error.status = 404;
throw error;
}
return group;
}
function getMemberDisplayName(member) {
return [member?.firstName, member?.lastName].filter(Boolean).join(' ').trim() || `Mitglied ${member?.id || ''}`.trim();
}
function getPrimaryEmail(member) {
const contacts = Array.isArray(member?.contacts) ? member.contacts : [];
const emailContacts = contacts.filter((contact) => contact?.type === 'email' && contact?.value);
const primaryContact = emailContacts.find((contact) => contact.isPrimary) || emailContacts[0];
return primaryContact?.value || member?.email || null;
}
function applyRecipientFilters(members, filters = {}, memberIdsWithMandate = new Set()) {
const normalized = normalizeRecipientFilters(filters);
return (Array.isArray(members) ? members : []).filter((member) => {
if (normalized.activeOnly && member?.active === false) return false;
if (normalized.testMembersOnly && !member?.testMembership) return false;
const email = trimText(getPrimaryEmail(member), 255);
if (normalized.onlyWithEmail && !email) return false;
if (normalized.missingEmail && email) return false;
const hasMandate = memberIdsWithMandate.has(Number(member?.id));
if (normalized.requiresSepaMandate && !hasMandate) return false;
if (normalized.missingSepaMandate && hasMandate) return false;
return true;
});
}
async function loadActiveSepaMemberIds(clubId) {
const mandates = await ClubSepaMandate.findAll({
where: {
clubId,
status: 'active',
revokedAt: null,
},
attributes: ['memberId'],
});
return new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function looksLikeHtml(value = '') {
return /<\/?[a-z][\s\S]*>/i.test(String(value || ''));
}
function stripHtml(value = '') {
return String(value || '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>\s*<p>/gi, '\n\n')
.replace(/<\/div>\s*<div>/gi, '\n')
.replace(/<li[^>]*>/gi, '\n- ')
.replace(/<\/(p|div|li|h[1-6]|blockquote)>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function renderMessageText(body) {
if (looksLikeHtml(body)) {
return stripHtml(body);
}
return String(body || '').trim();
}
function renderMessageHtml(body) {
if (looksLikeHtml(body)) {
return `<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#1f2937;">${String(body || '')}</div>`;
}
return `<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#1f2937;white-space:pre-wrap;">${escapeHtml(body).replace(/\n/g, '<br>')}</div>`;
}
function getLatestOutboundMessage(messages = []) {
const outboundMessages = messages.filter((message) => message?.messageType === 'message' && message?.direction === 'outbound');
return outboundMessages.at(-1) || null;
}
function classifyDeliveryError(error) {
const responseCode = Number(error?.responseCode || error?.response?.statusCode || 0) || null;
const code = String(error?.code || '').trim() || `SMTP_${responseCode || 'UNKNOWN'}`;
const message = trimText(error?.message, 500) || 'Unbekannter Versandfehler.';
const retryableCodes = new Set([
'ETIMEDOUT',
'ESOCKET',
'ECONNECTION',
'ECONNRESET',
'EAI_AGAIN',
'ENOTFOUND',
'EMESSAGE',
'EMAIL_CONFIG_MISSING',
]);
const retryableResponseCodes = new Set([421, 425, 429, 450, 451, 452]);
const retryable = retryableCodes.has(code) || (responseCode ? retryableResponseCodes.has(responseCode) : false);
return {
code,
message,
retryable,
responseCode,
};
}
function canAttemptDelivery(recipient) {
const hasEmail = !!trimText(recipient?.emailSnapshot, 255);
if (!hasEmail) {
return {
allowed: false,
reason: 'Keine E-Mail-Adresse hinterlegt.',
retryable: false,
code: 'MISSING_EMAIL',
};
}
if (recipient?.deliveryStatus === 'sent') {
return {
allowed: false,
reason: 'Empfänger wurde bereits erfolgreich beliefert.',
retryable: false,
code: 'ALREADY_SENT',
};
}
if (recipient?.deliveryStatus === 'failed' && recipient?.retryable === false) {
return {
allowed: false,
reason: recipient?.errorMessage || 'Versandfehler ist nicht erneut versendbar.',
retryable: false,
code: recipient?.errorCode || 'NON_RETRYABLE_FAILURE',
};
}
return {
allowed: true,
reason: '',
retryable: true,
code: null,
};
}
async function buildRecipientRowsForThread(clubId, threadPayload) {
let members = [];
const memberIdsWithMandate = await loadActiveSepaMemberIds(clubId);
if (threadPayload.threadType === 'direct' && threadPayload.recipientMemberId) {
members = await Member.findAll({
where: { clubId, id: threadPayload.recipientMemberId },
include: [{ model: MemberContact, as: 'contacts', required: false }],
});
} else if (threadPayload.threadType === 'group' && threadPayload.distributionGroupId) {
const group = await ClubDistributionGroup.findOne({
where: { id: threadPayload.distributionGroupId, clubId },
});
const memberships = await ClubDistributionGroupMember.findAll({
where: { groupId: threadPayload.distributionGroupId },
include: [{
model: Member,
as: 'member',
required: true,
where: { clubId, active: true },
include: [{ model: MemberContact, as: 'contacts', required: false }],
}],
});
members = applyRecipientFilters(
memberships.map((membership) => membership.member).filter(Boolean),
threadPayload.recipientFilters || group?.filterDefinition || {},
memberIdsWithMandate
);
} else if (threadPayload.threadType === 'broadcast') {
members = await Member.findAll({
where: { clubId, active: true },
include: [{ model: MemberContact, as: 'contacts', required: false }],
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
});
members = applyRecipientFilters(members, threadPayload.recipientFilters || {}, memberIdsWithMandate);
}
const uniqueMembers = new Map();
members.forEach((member) => {
const memberId = Number(member.id);
if (!uniqueMembers.has(memberId)) {
uniqueMembers.set(memberId, member);
}
});
return Array.from(uniqueMembers.values()).map((member) => {
const emailSnapshot = getPrimaryEmail(member);
return {
clubId,
memberId: Number(member.id),
recipientName: getMemberDisplayName(member),
emailSnapshot: emailSnapshot || null,
deliveryStatus: emailSnapshot ? 'pending' : 'failed',
deliveredAt: null,
lastAttemptAt: null,
attemptCount: 0,
retryable: false,
errorCode: emailSnapshot ? null : 'MISSING_EMAIL',
transportMessageId: null,
errorMessage: emailSnapshot ? null : 'Keine E-Mail-Adresse hinterlegt.',
};
});
}
async function replaceRecipientsForThread(thread, threadPayload, transaction) {
const recipients = await buildRecipientRowsForThread(thread.clubId, threadPayload);
await ClubCommunicationDeliveryLog.destroy({
where: { threadId: thread.id, clubId: thread.clubId },
transaction,
});
await ClubCommunicationRecipient.destroy({
where: { threadId: thread.id, clubId: thread.clubId },
transaction,
});
if (recipients.length > 0) {
await ClubCommunicationRecipient.bulkCreate(
recipients.map((recipient) => ({
...recipient,
threadId: thread.id,
})),
{ transaction }
);
}
}
class ClubCommunicationService {
async listClubCommunication(clubId) {
const [threads, groups, members, templates] = await Promise.all([
ClubCommunicationThread.findAll({
where: { clubId },
include: [
{ model: ClubCommunicationMessage, as: 'messages', required: false, include: [{ model: User, as: 'createdByUser', required: false }] },
{ model: ClubCommunicationRecipient, as: 'recipients', required: false, include: [{ model: Member, as: 'member', required: false }] },
{
model: ClubCommunicationDeliveryLog,
as: 'deliveryLogs',
required: false,
include: [
{ model: ClubCommunicationRecipient, as: 'recipient', required: false },
{ model: User, as: 'createdByUser', required: false },
],
},
{ model: ClubDistributionGroup, as: 'distributionGroup', required: false },
{ model: Member, as: 'recipientMember', required: false },
{ model: User, as: 'createdByUser', required: false },
],
order: [
['updatedAt', 'DESC'],
[{ model: ClubCommunicationMessage, as: 'messages' }, 'createdAt', 'ASC'],
[{ model: ClubCommunicationRecipient, as: 'recipients' }, 'recipientName', 'ASC'],
[{ model: ClubCommunicationDeliveryLog, as: 'deliveryLogs' }, 'createdAt', 'DESC'],
],
}),
ClubDistributionGroup.findAll({
where: { clubId },
include: [
{
model: ClubDistributionGroupMember,
as: 'memberships',
required: false,
include: [{ model: Member, as: 'member', required: false }],
},
],
order: [['isSystemGroup', 'DESC'], ['name', 'ASC']],
}),
Member.findAll({
where: { clubId, active: true },
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
}),
ClubCommunicationTemplate.findAll({
where: { clubId },
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
}),
]);
return { threads, groups, members, templates };
}
async createThread(clubId, userId, payload) {
const normalized = normalizeThreadPayload(payload);
ensureThreadTargets(normalized);
await ensureMemberBelongsToClub(clubId, normalized.recipientMemberId);
await ensureGroupBelongsToClub(clubId, normalized.distributionGroupId);
return sequelize.transaction(async (transaction) => {
const thread = await ClubCommunicationThread.create({
clubId,
createdByUserId: userId || null,
...normalized,
}, { transaction });
await replaceRecipientsForThread(thread, normalized, transaction);
return thread;
});
}
async updateThread(clubId, threadId, payload) {
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
if (!thread) {
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
error.status = 404;
throw error;
}
const normalized = normalizeThreadPayload(payload);
ensureThreadTargets(normalized);
await ensureMemberBelongsToClub(clubId, normalized.recipientMemberId);
await ensureGroupBelongsToClub(clubId, normalized.distributionGroupId);
await sequelize.transaction(async (transaction) => {
await thread.update({
...normalized,
sentAt: normalized.status === 'sent' ? (thread.sentAt || new Date()) : (normalized.status === 'archived' ? thread.sentAt : null),
}, { transaction });
await replaceRecipientsForThread(thread, normalized, transaction);
});
return thread;
}
async addMessage(clubId, threadId, userId, payload) {
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
if (!thread) {
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
error.status = 404;
throw error;
}
const normalized = normalizeMessagePayload(payload);
if (!normalized.body) {
const error = new Error('Nachrichtentext ist erforderlich.');
error.status = 400;
throw error;
}
const message = await ClubCommunicationMessage.create({
threadId: thread.id,
clubId,
createdByUserId: userId || null,
...normalized,
});
await thread.update({
status: thread.status === 'archived' ? 'archived' : thread.status,
sentAt: normalized.direction === 'outbound' && normalized.messageType === 'message' ? (thread.sentAt || new Date()) : thread.sentAt,
});
return message;
}
async sendThread(clubId, threadId, userId = null) {
const thread = await ClubCommunicationThread.findOne({
where: { id: threadId, clubId },
include: [
{
model: ClubCommunicationMessage,
as: 'messages',
required: false,
order: [['createdAt', 'ASC']],
},
{
model: ClubCommunicationRecipient,
as: 'recipients',
required: false,
order: [['recipientName', 'ASC']],
},
],
});
if (!thread) {
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
error.status = 404;
throw error;
}
const latestMessage = getLatestOutboundMessage(thread.messages || []);
if (!latestMessage?.body) {
const error = new Error('Zum Versand wird mindestens eine ausgehende Nachricht benötigt.');
error.status = 400;
throw error;
}
const recipients = Array.isArray(thread.recipients) ? thread.recipients : [];
let attemptedAny = false;
let processedAny = false;
if (recipients.length === 0) {
const error = new Error('Für diesen Kommunikationsvorgang sind keine Empfänger vorhanden.');
error.status = 400;
throw error;
}
for (const recipient of recipients) {
const eligibility = canAttemptDelivery(recipient);
if (!eligibility.allowed) {
if (!recipient.attemptCount && eligibility.code === 'MISSING_EMAIL') {
const now = new Date();
processedAny = true;
await sequelize.transaction(async (transaction) => {
await recipient.update({
deliveryStatus: 'failed',
lastAttemptAt: now,
attemptCount: Number(recipient.attemptCount || 0) + 1,
retryable: false,
errorCode: eligibility.code,
errorMessage: eligibility.reason,
deliveredAt: null,
transportMessageId: null,
}, { transaction });
await ClubCommunicationDeliveryLog.create({
clubId,
threadId: thread.id,
recipientId: recipient.id,
createdByUserId: userId || null,
status: 'failed',
attemptNo: Number(recipient.attemptCount || 0) + 1,
retryable: false,
errorCode: eligibility.code,
errorMessage: eligibility.reason,
}, { transaction });
});
}
continue;
}
attemptedAny = true;
processedAny = true;
const nextAttemptNo = Number(recipient.attemptCount || 0) + 1;
const now = new Date();
try {
const result = await sendClubCommunicationEmail({
to: recipient.emailSnapshot,
subject: thread.subject,
text: renderMessageText(latestMessage.body),
html: renderMessageHtml(latestMessage.body),
});
await sequelize.transaction(async (transaction) => {
await recipient.update({
deliveryStatus: 'sent',
deliveredAt: now,
lastAttemptAt: now,
attemptCount: nextAttemptNo,
retryable: false,
errorCode: null,
errorMessage: null,
transportMessageId: trimText(result?.messageId, 255),
}, { transaction });
await ClubCommunicationDeliveryLog.create({
clubId,
threadId: thread.id,
recipientId: recipient.id,
createdByUserId: userId || null,
status: 'sent',
attemptNo: nextAttemptNo,
retryable: false,
transportMessageId: trimText(result?.messageId, 255),
transportResponse: trimText(result?.response, 1000),
}, { transaction });
});
} catch (sendError) {
const classified = classifyDeliveryError(sendError);
console.error('[sendClubCommunicationThread] delivery failed', {
clubId,
threadId: thread.id,
recipientId: recipient.id,
email: recipient.emailSnapshot,
code: classified.code,
retryable: classified.retryable,
message: classified.message,
});
await sequelize.transaction(async (transaction) => {
await recipient.update({
deliveryStatus: 'failed',
deliveredAt: null,
lastAttemptAt: now,
attemptCount: nextAttemptNo,
retryable: classified.retryable,
errorCode: classified.code,
errorMessage: classified.message,
transportMessageId: null,
}, { transaction });
await ClubCommunicationDeliveryLog.create({
clubId,
threadId: thread.id,
recipientId: recipient.id,
createdByUserId: userId || null,
status: 'failed',
attemptNo: nextAttemptNo,
retryable: classified.retryable,
errorCode: classified.code,
errorMessage: classified.message,
transportResponse: trimText(sendError?.response, 1000),
}, { transaction });
});
}
}
if (!processedAny) {
const error = new Error('Es gibt aktuell keine erneut versendbaren Empfänger.');
error.status = 400;
throw error;
}
await thread.update({
status: 'sent',
sentAt: thread.sentAt || new Date(),
});
return thread;
}
async deleteThread(clubId, threadId) {
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
if (!thread) {
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
error.status = 404;
throw error;
}
await sequelize.transaction(async (transaction) => {
await ClubCommunicationMessage.destroy({
where: { threadId: thread.id, clubId },
transaction,
});
await ClubCommunicationRecipient.destroy({
where: { threadId: thread.id, clubId },
transaction,
});
await ClubCommunicationDeliveryLog.destroy({
where: { threadId: thread.id, clubId },
transaction,
});
await thread.destroy({ transaction });
});
}
async createGroup(clubId, payload) {
const normalized = normalizeGroupPayload(payload);
if (!normalized.name) {
const error = new Error('Gruppenname ist erforderlich.');
error.status = 400;
throw error;
}
return sequelize.transaction(async (transaction) => {
const group = await ClubDistributionGroup.create({
clubId,
name: normalized.name,
description: normalized.description,
groupType: normalized.groupType,
filterDefinition: normalized.filterDefinition,
}, { transaction });
if (normalized.memberIds.length > 0) {
await ClubDistributionGroupMember.bulkCreate(
normalized.memberIds.map((memberId) => ({ groupId: group.id, memberId })),
{ transaction }
);
}
return group;
});
}
async updateGroup(clubId, groupId, payload) {
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
if (!group) {
const error = new Error('Verteilergruppe wurde nicht gefunden.');
error.status = 404;
throw error;
}
const normalized = normalizeGroupPayload(payload);
if (!normalized.name) {
const error = new Error('Gruppenname ist erforderlich.');
error.status = 400;
throw error;
}
await sequelize.transaction(async (transaction) => {
await group.update({
name: normalized.name,
description: normalized.description,
groupType: normalized.groupType,
filterDefinition: normalized.filterDefinition,
}, { transaction });
await ClubDistributionGroupMember.destroy({
where: { groupId: group.id },
transaction,
});
if (normalized.memberIds.length > 0) {
await ClubDistributionGroupMember.bulkCreate(
normalized.memberIds.map((memberId) => ({ groupId: group.id, memberId })),
{ transaction }
);
}
});
return group;
}
async deleteGroup(clubId, groupId) {
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
if (!group) {
const error = new Error('Verteilergruppe wurde nicht gefunden.');
error.status = 404;
throw error;
}
const linkedThreads = await ClubCommunicationThread.count({
where: { clubId, distributionGroupId: group.id },
});
if (linkedThreads > 0) {
const error = new Error('Verteilergruppe kann nicht gelöscht werden, weil noch Kommunikationsvorgänge darauf verweisen.');
error.status = 400;
throw error;
}
await sequelize.transaction(async (transaction) => {
await ClubDistributionGroupMember.destroy({ where: { groupId: group.id }, transaction });
await group.destroy({ transaction });
});
}
async createTemplate(clubId, payload) {
const normalized = normalizeTemplatePayload(payload);
if (!normalized.name || !normalized.bodyTemplate) {
const error = new Error('Vorlagenname und Nachrichtentext sind erforderlich.');
error.status = 400;
throw error;
}
return ClubCommunicationTemplate.create({
clubId,
...normalized,
});
}
async updateTemplate(clubId, templateId, payload) {
const template = await ClubCommunicationTemplate.findOne({
where: { id: templateId, clubId },
});
if (!template) {
const error = new Error('Vorlage wurde nicht gefunden.');
error.status = 404;
throw error;
}
if (template.isSystemTemplate) {
const error = new Error('Systemvorlagen können nicht bearbeitet werden.');
error.status = 400;
throw error;
}
const normalized = normalizeTemplatePayload(payload);
if (!normalized.name || !normalized.bodyTemplate) {
const error = new Error('Vorlagenname und Nachrichtentext sind erforderlich.');
error.status = 400;
throw error;
}
await template.update(normalized);
return template;
}
async deleteTemplate(clubId, templateId) {
const template = await ClubCommunicationTemplate.findOne({
where: { id: templateId, clubId },
});
if (!template) {
const error = new Error('Vorlage wurde nicht gefunden.');
error.status = 404;
throw error;
}
if (template.isSystemTemplate) {
const error = new Error('Systemvorlagen können nicht gelöscht werden.');
error.status = 400;
throw error;
}
await template.destroy();
return { success: true };
}
}
export default new ClubCommunicationService();

View File

@@ -0,0 +1,361 @@
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import sequelize from '../database.js';
import Club from '../models/Club.js';
import ClubDocument from '../models/ClubDocument.js';
import ClubDocumentVersion from '../models/ClubDocumentVersion.js';
import ClubDocumentLink from '../models/ClubDocumentLink.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DOCUMENT_TYPES = new Set(['satzung', 'protokoll', 'nachweis', 'formular', 'vertrag', 'rechnung', 'other']);
const DOCUMENT_STATUSES = new Set(['active', 'draft', 'archived', 'obsolete']);
const VISIBILITY_SCOPES = new Set(['board', 'finance', 'trainers', 'all']);
function trimText(value, maxLength = null) {
const normalized = String(value || '').trim();
if (!normalized) return null;
return maxLength ? normalized.slice(0, maxLength) : normalized;
}
function normalizePayload(payload = {}) {
return {
documentType: DOCUMENT_TYPES.has(payload.documentType) ? payload.documentType : 'other',
title: trimText(payload.title, 255),
description: trimText(payload.description),
status: DOCUMENT_STATUSES.has(payload.status) ? payload.status : 'active',
visibilityScope: VISIBILITY_SCOPES.has(payload.visibilityScope) ? payload.visibilityScope : 'board',
changeNote: trimText(payload.changeNote),
linkedEntityType: trimText(payload.linkedEntityType, 32),
linkedEntityId: payload.linkedEntityId ? Number(payload.linkedEntityId) : null,
};
}
function normalizeFileName(value = '') {
return String(value || 'document').replace(/[^a-zA-Z0-9._-]+/g, '_');
}
function getStorageDir(clubId) {
return path.join(__dirname, '..', 'uploads', 'club-documents', String(clubId));
}
function ensureStorageDir(clubId) {
const uploadDir = getStorageDir(clubId);
fs.mkdirSync(uploadDir, { recursive: true });
return uploadDir;
}
function checksumFile(filePath) {
const hash = crypto.createHash('sha256');
const fileBuffer = fs.readFileSync(filePath);
hash.update(fileBuffer);
return hash.digest('hex');
}
function moveUploadedFile(tempPath, destinationPath) {
try {
fs.renameSync(tempPath, destinationPath);
} catch (error) {
if (error.code === 'EXDEV') {
fs.copyFileSync(tempPath, destinationPath);
fs.unlinkSync(tempPath);
return;
}
throw error;
}
}
function buildDocumentSnapshot(document, versions = [], links = []) {
const orderedVersions = [...versions].sort((a, b) => Number(b.versionNo || 0) - Number(a.versionNo || 0));
const latestVersion = orderedVersions[0] || null;
return {
...document.get({ plain: true }),
versionCount: versions.length,
latestVersion: latestVersion ? latestVersion.get({ plain: true }) : null,
versions: orderedVersions.map((version) => version.get({ plain: true })),
links: links.map((link) => link.get({ plain: true })),
linkedEntityCount: links.length,
};
}
class ClubDocumentService {
async listClubDocuments(clubId, filters = {}) {
const [club, documents] = await Promise.all([
Club.findByPk(clubId, { attributes: ['id'] }),
ClubDocument.findAll({
where: { clubId },
include: [
{ model: ClubDocumentVersion, as: 'versions', required: false },
{ model: ClubDocumentLink, as: 'links', required: false },
],
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
}),
]);
if (!club) {
const error = new Error('Verein wurde nicht gefunden.');
error.status = 404;
throw error;
}
const search = trimText(filters.search)?.toLowerCase() || '';
const typeFilter = trimText(filters.documentType);
const statusFilter = trimText(filters.status);
const visibilityFilter = trimText(filters.visibilityScope);
return documents
.filter((document) => {
if (typeFilter && document.documentType !== typeFilter) return false;
if (statusFilter && document.status !== statusFilter) return false;
if (visibilityFilter && document.visibilityScope !== visibilityFilter) return false;
if (!search) return true;
const haystack = [
document.title,
document.description,
document.documentType,
document.status,
document.visibilityScope,
...(document.versions || []).map((version) => version.fileName),
...(document.links || []).map((link) => `${link.linkedEntityType} ${link.linkedEntityId}`),
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(search);
})
.map((document) => buildDocumentSnapshot(document, document.versions || [], document.links || []));
}
async createClubDocument(clubId, userId, payload = {}, file = null) {
const normalized = normalizePayload(payload);
if (!normalized.title) {
const error = new Error('Bitte einen Dokumenttitel angeben.');
error.status = 400;
throw error;
}
if (!file) {
const error = new Error('Eine Datei ist für das neue Dokument erforderlich.');
error.status = 400;
throw error;
}
const club = await Club.findByPk(clubId, { attributes: ['id'] });
if (!club) {
const error = new Error('Verein wurde nicht gefunden.');
error.status = 404;
throw error;
}
const uploadDir = ensureStorageDir(clubId);
const safeFileName = normalizeFileName(file.originalname);
const storageFileName = `${Date.now()}_${safeFileName}`;
const storagePath = path.join(uploadDir, storageFileName);
moveUploadedFile(file.path, storagePath);
const checksum = checksumFile(storagePath);
const transaction = await sequelize.transaction();
try {
const document = await ClubDocument.create({
clubId,
documentType: normalized.documentType,
title: normalized.title,
description: normalized.description,
status: normalized.status,
visibilityScope: normalized.visibilityScope,
ownerUserId: userId || null,
currentVersionNo: 1,
}, { transaction });
const version = await ClubDocumentVersion.create({
documentId: document.id,
versionNo: 1,
fileName: safeFileName,
storagePath,
mimeType: file.mimetype,
fileSizeBytes: file.size,
checksumSha256: checksum,
uploadedByUserId: userId || null,
changeNote: normalized.changeNote,
}, { transaction });
if (normalized.linkedEntityType && normalized.linkedEntityId) {
await ClubDocumentLink.create({
documentId: document.id,
linkedEntityType: normalized.linkedEntityType,
linkedEntityId: normalized.linkedEntityId,
}, { transaction });
}
await transaction.commit();
return this.getClubDocumentById(clubId, document.id);
} catch (error) {
await transaction.rollback();
if (fs.existsSync(storagePath)) {
fs.unlinkSync(storagePath);
}
throw error;
}
}
async updateClubDocument(clubId, documentId, userId, payload = {}, file = null) {
const normalized = normalizePayload(payload);
const document = await ClubDocument.findOne({
where: { id: documentId, clubId },
include: [
{ model: ClubDocumentVersion, as: 'versions', required: false },
{ model: ClubDocumentLink, as: 'links', required: false },
],
});
if (!document) {
const error = new Error('Dokument wurde nicht gefunden.');
error.status = 404;
throw error;
}
const transaction = await sequelize.transaction();
let uploadedStoragePath = null;
try {
await document.update({
title: normalized.title || document.title,
description: normalized.description,
status: normalized.status,
visibilityScope: normalized.visibilityScope,
documentType: normalized.documentType,
}, { transaction });
if (file) {
const uploadDir = ensureStorageDir(clubId);
const nextVersionNo = Number(document.currentVersionNo || 1) + 1;
const safeFileName = normalizeFileName(file.originalname);
const storageFileName = `${Date.now()}_v${nextVersionNo}_${safeFileName}`;
const storagePath = path.join(uploadDir, storageFileName);
moveUploadedFile(file.path, storagePath);
uploadedStoragePath = storagePath;
const checksum = checksumFile(storagePath);
await ClubDocumentVersion.create({
documentId: document.id,
versionNo: nextVersionNo,
fileName: safeFileName,
storagePath,
mimeType: file.mimetype,
fileSizeBytes: file.size,
checksumSha256: checksum,
uploadedByUserId: userId || null,
changeNote: normalized.changeNote,
}, { transaction });
await document.update({
currentVersionNo: nextVersionNo,
archivedAt: normalized.status === 'archived' ? new Date() : null,
status: normalized.status,
}, { transaction });
}
if (normalized.linkedEntityType && normalized.linkedEntityId) {
await ClubDocumentLink.findOrCreate({
where: {
documentId: document.id,
linkedEntityType: normalized.linkedEntityType,
linkedEntityId: normalized.linkedEntityId,
},
defaults: {
documentId: document.id,
linkedEntityType: normalized.linkedEntityType,
linkedEntityId: normalized.linkedEntityId,
},
transaction,
});
}
await transaction.commit();
return this.getClubDocumentById(clubId, document.id);
} catch (error) {
await transaction.rollback();
if (uploadedStoragePath && fs.existsSync(uploadedStoragePath)) {
fs.unlinkSync(uploadedStoragePath);
}
throw error;
}
}
async archiveClubDocument(clubId, documentId) {
const document = await ClubDocument.findOne({ where: { id: documentId, clubId } });
if (!document) {
const error = new Error('Dokument wurde nicht gefunden.');
error.status = 404;
throw error;
}
await document.update({
status: 'archived',
archivedAt: new Date(),
});
return this.getClubDocumentById(clubId, document.id);
}
async deleteClubDocument(clubId, documentId) {
const document = await ClubDocument.findOne({
where: { id: documentId, clubId },
include: [{ model: ClubDocumentVersion, as: 'versions', required: false }],
});
if (!document) {
const error = new Error('Dokument wurde nicht gefunden.');
error.status = 404;
throw error;
}
for (const version of document.versions || []) {
if (version.storagePath && fs.existsSync(version.storagePath)) {
fs.unlinkSync(version.storagePath);
}
}
await ClubDocumentLink.destroy({ where: { documentId } });
await ClubDocumentVersion.destroy({ where: { documentId } });
await document.destroy();
return true;
}
async getClubDocumentById(clubId, documentId) {
const document = await ClubDocument.findOne({
where: { id: documentId, clubId },
include: [
{ model: ClubDocumentVersion, as: 'versions', required: false },
{ model: ClubDocumentLink, as: 'links', required: false },
],
});
if (!document) {
return null;
}
return buildDocumentSnapshot(document, document.versions || [], document.links || []);
}
async getDocumentDownload(clubId, documentId, versionNo = null) {
const document = await ClubDocument.findOne({
where: { id: documentId, clubId },
include: [{ model: ClubDocumentVersion, as: 'versions', required: false }],
});
if (!document) {
const error = new Error('Dokument wurde nicht gefunden.');
error.status = 404;
throw error;
}
const versions = document.versions || [];
const selectedVersion = versionNo
? versions.find((version) => Number(version.versionNo) === Number(versionNo))
: [...versions].sort((a, b) => Number(b.versionNo || 0) - Number(a.versionNo || 0))[0];
if (!selectedVersion) {
const error = new Error('Dokumentversion wurde nicht gefunden.');
error.status = 404;
throw error;
}
return { document: document.get({ plain: true }), version: selectedVersion.get({ plain: true }) };
}
}
export default new ClubDocumentService();

View File

@@ -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 });
});

View File

@@ -0,0 +1,409 @@
import { Op, Transaction } from 'sequelize';
import sequelize from '../database.js';
import { ClubAccount, ClubAccountTransaction, ClubPaymentClaim, Member } from '../models/index.js';
const CLAIM_TYPES = new Set(['membership_fee', 'additional_fee', 'course_fee', 'penalty_fee', 'other']);
const CLAIM_STATUSES = new Set(['open', 'partially_paid', 'paid', 'written_off', 'cancelled']);
function trimText(value, maxLength = null) {
const normalized = String(value || '').trim();
if (!normalized) return null;
return maxLength ? normalized.slice(0, maxLength) : normalized;
}
function normalizePayload(payload = {}) {
return {
memberId: Number(payload.memberId) || null,
feeRuleId: Number(payload.feeRuleId) || null,
claimType: CLAIM_TYPES.has(payload.claimType) ? payload.claimType : 'membership_fee',
status: CLAIM_STATUSES.has(payload.status) ? payload.status : 'open',
dueOn: trimText(payload.dueOn, 10),
amountCents: Number.parseInt(payload.amountCents, 10) || 0,
paidAmountCents: Math.max(0, Number.parseInt(payload.paidAmountCents, 10) || 0),
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
reminderLevel: Math.max(0, Number.parseInt(payload.reminderLevel, 10) || 0),
notes: trimText(payload.notes),
};
}
function resolveReminderTimestamp(existingClaim, nextReminderLevel) {
const previousReminderLevel = Math.max(0, Number.parseInt(existingClaim?.reminderLevel, 10) || 0);
if (nextReminderLevel <= 0) return null;
if (nextReminderLevel > previousReminderLevel) return new Date();
return existingClaim?.lastReminderAt || new Date();
}
function clampPaidAmount(amountCents, paidAmountCents) {
return Math.max(0, Math.min(Number(amountCents || 0), Number(paidAmountCents || 0)));
}
function deriveFinancialStatus(status, amountCents, paidAmountCents) {
if (status === 'cancelled' || status === 'written_off') return status;
if (Number(amountCents || 0) <= 0) return 'open';
if (paidAmountCents >= amountCents) return 'paid';
if (paidAmountCents > 0) return 'partially_paid';
return 'open';
}
function deriveStatusFromBalance(amountCents, paidAmountCents) {
if (Number(amountCents || 0) <= 0) return 'open';
if (paidAmountCents >= amountCents) return 'paid';
if (paidAmountCents > 0) return 'partially_paid';
return 'open';
}
function validatePayload(payload) {
if (!payload.memberId) {
const error = new Error('Mitglied ist erforderlich.');
error.status = 400;
throw error;
}
if (!payload.dueOn) {
const error = new Error('Fälligkeitsdatum ist erforderlich.');
error.status = 400;
throw error;
}
if (!Number.isFinite(Number(payload.amountCents)) || Number(payload.amountCents) <= 0) {
const error = new Error('Der Betrag muss größer als 0 sein.');
error.status = 400;
throw error;
}
}
async function ensureMemberBelongsToClub(clubId, memberId) {
const member = await Member.findOne({ where: { id: memberId, clubId } });
if (!member) {
const error = new Error('Mitglied wurde nicht gefunden.');
error.status = 404;
throw error;
}
return member;
}
class ClubPaymentClaimService {
async listClaims(clubId) {
const claims = await ClubPaymentClaim.findAll({
where: {
clubId,
[Op.or]: [
{ archivedAt: null },
{ archivedAt: { [Op.is]: null } },
],
status: { [Op.notIn]: ['written_off', 'cancelled'] },
},
include: [
{ model: Member, as: 'member', required: false },
{ model: ClubAccountTransaction, as: 'transactions', required: false },
],
order: [['dueOn', 'ASC'], ['updatedAt', 'DESC']],
});
return { claims };
}
async createClaim(clubId, payload) {
const normalized = normalizePayload(payload);
validatePayload(normalized);
await ensureMemberBelongsToClub(clubId, normalized.memberId);
const paidAmountCents = clampPaidAmount(normalized.amountCents, normalized.paidAmountCents);
const status = deriveFinancialStatus(normalized.status, normalized.amountCents, paidAmountCents);
const claim = await ClubPaymentClaim.create({
clubId,
...normalized,
paidAmountCents,
status,
settledAt: status === 'paid' ? new Date() : null,
lastPaidAt: paidAmountCents > 0 ? new Date() : null,
lastReminderAt: normalized.reminderLevel > 0 ? new Date() : null,
});
return claim.reload({
include: [
{ model: Member, as: 'member', required: false },
{ model: ClubAccountTransaction, as: 'transactions', required: false },
],
});
}
async updateClaim(clubId, claimId, payload) {
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
if (!claim) {
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
error.status = 404;
throw error;
}
const normalized = normalizePayload(payload);
validatePayload(normalized);
await ensureMemberBelongsToClub(clubId, normalized.memberId);
const paidAmountCents = clampPaidAmount(normalized.amountCents, normalized.paidAmountCents);
const status = deriveFinancialStatus(normalized.status, normalized.amountCents, paidAmountCents);
const previousPaidAmount = Number(claim.paidAmountCents || 0);
await claim.update({
...normalized,
paidAmountCents,
status,
settledAt: status === 'paid' ? (claim.settledAt || new Date()) : null,
lastPaidAt: paidAmountCents > previousPaidAmount ? new Date() : (paidAmountCents > 0 ? (claim.lastPaidAt || new Date()) : null),
lastReminderAt: resolveReminderTimestamp(claim, normalized.reminderLevel),
archivedAt: ['written_off', 'cancelled'].includes(status) ? (claim.archivedAt || new Date()) : null,
});
return claim.reload({
include: [
{ model: Member, as: 'member', required: false },
{ model: ClubAccountTransaction, as: 'transactions', required: false },
],
});
}
async updateClaimStatus(clubId, claimId, status) {
if (!CLAIM_STATUSES.has(status)) {
const error = new Error('Ungültiger Zahlungsstatus.');
error.status = 400;
throw error;
}
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
if (!claim) {
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
error.status = 404;
throw error;
}
await claim.update({
status,
paidAmountCents: status === 'paid' ? Number(claim.amountCents || 0) : Number(claim.paidAmountCents || 0),
settledAt: status === 'paid' ? (claim.settledAt || new Date()) : null,
lastPaidAt: status === 'paid' ? (claim.lastPaidAt || new Date()) : claim.lastPaidAt,
archivedAt: ['written_off', 'cancelled'].includes(status) ? (claim.archivedAt || new Date()) : null,
});
return claim.reload({
include: [
{ model: Member, as: 'member', required: false },
{ model: ClubAccountTransaction, as: 'transactions', required: false },
],
});
}
async deleteClaim(clubId, claimId) {
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
if (!claim) {
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
error.status = 404;
throw error;
}
await claim.destroy();
return { success: true };
}
async registerPayment(clubId, claimId, payload = {}) {
const amountCents = Number.parseInt(payload.amountCents, 10) || 0;
if (amountCents <= 0) {
const error = new Error('Der Zahlungsbetrag muss größer als 0 sein.');
error.status = 400;
throw error;
}
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
if (!claim) {
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
error.status = 404;
throw error;
}
if (['cancelled', 'written_off', 'paid'].includes(claim.status)) {
const error = new Error('Für diesen Status kann keine Teilzahlung mehr erfasst werden.');
error.status = 400;
throw error;
}
const accountId = Number(payload.accountId) || null;
let account = null;
if (accountId) {
account = await ClubAccount.findOne({ where: { id: accountId, clubId } });
if (!account) {
const error = new Error('Konto wurde nicht gefunden.');
error.status = 404;
throw error;
}
}
const nextPaidAmount = clampPaidAmount(claim.amountCents, Number(claim.paidAmountCents || 0) + amountCents);
const actualPaymentAmountCents = nextPaidAmount - Number(claim.paidAmountCents || 0);
if (actualPaymentAmountCents <= 0) {
const error = new Error('Die Forderung ist bereits vollständig beglichen.');
error.status = 400;
throw error;
}
const nextStatus = deriveFinancialStatus(claim.status, claim.amountCents, nextPaidAmount);
const note = trimText(payload.note);
const notePrefix = `Teilzahlung ${formatMoney(actualPaymentAmountCents, claim.currencyCode)} erfasst`;
const appendedNotes = notePrefix
? [claim.notes, `${notePrefix}${note ? `: ${note}` : ''}`].filter(Boolean).join('\n')
: claim.notes;
const bookingDate = trimText(payload.bookingDate, 10) || new Date().toISOString().slice(0, 10);
await sequelize.transaction(async (transaction) => {
await this.applyPaymentToClaim(clubId, claim, {
amountCents: actualPaymentAmountCents,
paidAmountCents: nextPaidAmount,
status: nextStatus,
notes: appendedNotes,
}, transaction);
if (account) {
await ClubAccountTransaction.create({
clubId,
accountId: account.id,
paymentClaimId: claim.id,
direction: 'credit',
bookingType: 'payment_claim',
status: 'booked',
bookingDate,
valueDate: bookingDate,
amountCents: actualPaymentAmountCents,
currencyCode: claim.currencyCode || account.currencyCode || 'EUR',
reference: payload.reference ? trimText(payload.reference, 255) : `Forderung #${claim.id}`,
notes: note || `Zahlung zu Forderung #${claim.id}`,
}, { transaction });
}
});
return claim.reload({
include: [
{ model: Member, as: 'member', required: false },
{ model: ClubAccountTransaction, as: 'transactions', required: false },
],
});
}
async reconcileFromTransactions(clubId, claimId, dbTransaction = null) {
const claim = await ClubPaymentClaim.findOne({
where: { id: claimId, clubId },
include: [
{ model: Member, as: 'member', required: false },
{ model: ClubAccountTransaction, as: 'transactions', required: false },
],
transaction: dbTransaction || undefined,
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
});
if (!claim) {
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
error.status = 404;
throw error;
}
if (['cancelled', 'written_off'].includes(claim.status)) {
return claim;
}
const transactions = await ClubAccountTransaction.findAll({
where: {
clubId,
paymentClaimId: claim.id,
direction: 'credit',
status: 'booked',
},
order: [['bookingDate', 'ASC'], ['createdAt', 'ASC']],
transaction: dbTransaction || undefined,
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
});
const paidAmountCents = transactions.reduce((sum, transaction) => sum + Number(transaction.amountCents || 0), 0);
const latestPaymentTransaction = transactions[transactions.length - 1] || null;
const nextStatus = deriveStatusFromBalance(Number(claim.amountCents || 0), paidAmountCents);
await claim.update({
paidAmountCents,
status: nextStatus,
settledAt: nextStatus === 'paid' ? (claim.settledAt || new Date()) : null,
lastPaidAt: paidAmountCents > 0
? (latestPaymentTransaction?.bookingDate ? new Date(`${latestPaymentTransaction.bookingDate}T00:00:00`) : (claim.lastPaidAt || new Date()))
: null,
archivedAt: nextStatus === 'paid' ? null : claim.archivedAt,
}, dbTransaction ? { transaction: dbTransaction } : undefined);
return claim.reload({
include: [
{ model: Member, as: 'member', required: false },
{ model: ClubAccountTransaction, as: 'transactions', required: false },
],
transaction: dbTransaction || undefined,
});
}
async applyPaymentToClaim(clubId, claimOrId, payment, dbTransaction = null) {
const claimId = typeof claimOrId === 'object' && claimOrId ? claimOrId.id : claimOrId;
const claim = typeof claimOrId === 'object' && claimOrId ? claimOrId : await ClubPaymentClaim.findOne({
where: { id: claimId, clubId },
transaction: dbTransaction || undefined,
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
});
if (!claim) {
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
error.status = 404;
throw error;
}
if (['cancelled', 'written_off', 'paid'].includes(claim.status)) {
const error = new Error('Für diesen Status kann keine Zahlung mehr erfasst werden.');
error.status = 400;
throw error;
}
const incomingAmountCents = Number.parseInt(payment?.amountCents, 10) || 0;
if (incomingAmountCents <= 0) {
const error = new Error('Der Zahlungsbetrag muss größer als 0 sein.');
error.status = 400;
throw error;
}
const currentPaidAmount = Number(claim.paidAmountCents || 0);
const nextPaidAmount = clampPaidAmount(claim.amountCents, payment.paidAmountCents != null
? Number(payment.paidAmountCents)
: currentPaidAmount + incomingAmountCents);
const appliedAmountCents = nextPaidAmount - currentPaidAmount;
if (appliedAmountCents <= 0) {
const error = new Error('Die Forderung ist bereits vollständig beglichen.');
error.status = 400;
throw error;
}
const nextStatus = payment.status || deriveFinancialStatus(claim.status, claim.amountCents, nextPaidAmount);
await claim.update({
paidAmountCents: nextPaidAmount,
status: nextStatus,
settledAt: nextStatus === 'paid' ? (claim.settledAt || new Date()) : null,
lastPaidAt: new Date(),
notes: payment.notes != null ? payment.notes : claim.notes,
lastReminderAt: payment.lastReminderAt != null ? payment.lastReminderAt : claim.lastReminderAt,
}, dbTransaction ? { transaction: dbTransaction } : undefined);
return {
claim: await claim.reload({
include: [
{ model: Member, as: 'member', required: false },
{ model: ClubAccountTransaction, as: 'transactions', required: false },
],
transaction: dbTransaction || undefined,
}),
appliedAmountCents,
nextPaidAmount,
nextStatus,
};
}
}
function formatMoney(amountCents, currencyCode = 'EUR') {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: currencyCode || 'EUR',
}).format(Number(amountCents || 0) / 100);
}
export default new ClubPaymentClaimService();

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -1,27 +1,49 @@
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
service: 'Gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
let transporter = null;
function getTransporter() {
if (!process.env.EMAIL_USER || !process.env.EMAIL_PASS) {
const error = new Error('E-Mail-Versand ist nicht konfiguriert.');
error.code = 'EMAIL_CONFIG_MISSING';
throw error;
}
if (!transporter) {
transporter = nodemailer.createTransport({
service: 'Gmail',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
}
return transporter;
}
function getDefaultFrom() {
return process.env.EMAIL_FROM || process.env.EMAIL_USER;
}
async function sendMail(mailOptions) {
return getTransporter().sendMail({
from: getDefaultFrom(),
...mailOptions,
});
}
const sendActivationEmail = async (email, activationCode) => {
const mailOptions = {
from: process.env.EMAIL_USER,
await sendMail({
to: email,
subject: 'Account Activation',
text: `Activate your account by clicking the following link: ${process.env.BASE_URL}/activate/${activationCode}`,
};
await transporter.sendMail(mailOptions);
});
};
const sendPasswordResetEmail = async (email, resetToken) => {
const resetLink = `${process.env.BASE_URL}/reset-password/${resetToken}`;
const mailOptions = {
from: process.env.EMAIL_USER,
await sendMail({
to: email,
subject: 'Passwort zurücksetzen',
html: `
@@ -46,8 +68,7 @@ const sendPasswordResetEmail = async (email, resetToken) => {
</p>
</div>
`,
};
await transporter.sendMail(mailOptions);
});
};
const sendFriendlyMatchInvitationEmail = async ({
@@ -68,8 +89,7 @@ const sendFriendlyMatchInvitationEmail = async ({
? `<p style="margin-top: 12px;"><strong>Nachricht:</strong><br>${String(message).replace(/</g, '&lt;').replace(/>/g, '&gt;')}</p>`
: '';
const mailOptions = {
from: process.env.EMAIL_USER,
await sendMail({
to: recipientList.join(','),
subject: `Freundschaftsspiel-Einladung: ${fromClubName} -> ${toClubName}`,
html: `
@@ -90,9 +110,7 @@ const sendFriendlyMatchInvitationEmail = async ({
</p>
</div>
`,
};
await transporter.sendMail(mailOptions);
});
};
const escapeHtml = (value) => String(value ?? '')
@@ -110,8 +128,7 @@ const sendMobileFeedbackEmail = async ({
backendBaseUrl,
user,
}) => {
const mailOptions = {
from: process.env.EMAIL_USER,
await sendMail({
to: 'tsschulz2001@gmail.com',
subject: `Android Feedback${screen ? ` - ${screen}` : ''}`,
html: `
@@ -129,9 +146,23 @@ const sendMobileFeedbackEmail = async ({
<div style="white-space:pre-wrap;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;">${escapeHtml(message)}</div>
</div>
`,
};
await transporter.sendMail(mailOptions);
});
};
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail };
const sendClubCommunicationEmail = async ({
to,
subject,
text,
html,
replyTo,
}) => {
return sendMail({
to,
subject,
text,
html,
replyTo: replyTo || undefined,
});
};
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail, sendClubCommunicationEmail };

View File

@@ -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

View File

@@ -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;