Updates, overview extended, club view implemented
This commit is contained in:
@@ -17,25 +17,31 @@ class CalendarEventService {
|
||||
});
|
||||
}
|
||||
|
||||
async createClubEvent(userToken, clubId, payload) {
|
||||
async createClubEvent(userToken, clubId, payload, userId = null) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const title = String(payload?.title || '').trim();
|
||||
if (!title) throw new HttpError('Titel fehlt', 400);
|
||||
const startDate = this.normalizeDate(payload?.startDate);
|
||||
const endDate = this.normalizeDate(payload?.endDate || payload?.startDate);
|
||||
if (!startDate || !endDate) throw new HttpError('Ungültiges Datum', 400);
|
||||
if (startDate > endDate) throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400);
|
||||
const normalized = this.normalizePayload(payload);
|
||||
this.validatePayload(normalized);
|
||||
|
||||
return await CalendarEvent.create({
|
||||
clubId,
|
||||
title,
|
||||
startDate,
|
||||
endDate,
|
||||
category: payload?.category ? String(payload.category).trim().slice(0, 64) : null,
|
||||
notes: payload?.notes ? String(payload.notes).trim() : null,
|
||||
...normalized,
|
||||
organizerUserId: Number(userId || payload?.organizerUserId || 0) || null,
|
||||
});
|
||||
}
|
||||
|
||||
async updateClubEvent(userToken, clubId, eventId, payload, userId = null) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
|
||||
if (!event) throw new HttpError('Event nicht gefunden', 404);
|
||||
const normalized = this.normalizePayload(payload);
|
||||
this.validatePayload(normalized);
|
||||
await event.update({
|
||||
...normalized,
|
||||
organizerUserId: Number(userId || payload?.organizerUserId || event.organizerUserId || 0) || null,
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
async deleteClubEvent(userToken, clubId, eventId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
|
||||
@@ -54,6 +60,34 @@ class CalendarEventService {
|
||||
const text = String(date || '').slice(0, 10);
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
|
||||
}
|
||||
|
||||
normalizePayload(payload = {}) {
|
||||
return {
|
||||
title: String(payload?.title || '').trim().slice(0, 255),
|
||||
eventType: ['club_event', 'meeting', 'tournament', 'social', 'workshop', 'other'].includes(payload?.eventType)
|
||||
? payload.eventType
|
||||
: 'club_event',
|
||||
status: ['planning', 'invited', 'confirmed', 'done', 'cancelled'].includes(payload?.status)
|
||||
? payload.status
|
||||
: 'planning',
|
||||
description: payload?.description ? String(payload.description).trim() : null,
|
||||
location: payload?.location ? String(payload.location).trim().slice(0, 255) : null,
|
||||
startDate: this.normalizeDate(payload?.startDate),
|
||||
endDate: this.normalizeDate(payload?.endDate || payload?.startDate),
|
||||
registrationDeadline: this.normalizeDate(payload?.registrationDeadline),
|
||||
category: payload?.category ? String(payload.category).trim().slice(0, 64) : null,
|
||||
notes: payload?.notes ? String(payload.notes).trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
validatePayload(payload) {
|
||||
if (!payload.title) throw new HttpError('Titel fehlt', 400);
|
||||
if (!payload.startDate || !payload.endDate) throw new HttpError('Ungültiges Datum', 400);
|
||||
if (payload.startDate > payload.endDate) throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400);
|
||||
if (payload.registrationDeadline && payload.registrationDeadline > payload.startDate) {
|
||||
throw new HttpError('Anmeldefrist darf nicht nach dem Startdatum liegen', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new CalendarEventService();
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Op } from 'sequelize';
|
||||
import { Op, Transaction } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import ClubAccount from '../models/ClubAccount.js';
|
||||
import ClubAccountTransaction from '../models/ClubAccountTransaction.js';
|
||||
import { ClubPaymentClaim, Member } from '../models/index.js';
|
||||
import clubPaymentClaimService from './clubPaymentClaimService.js';
|
||||
|
||||
const TRANSACTION_DIRECTIONS = new Set(['credit', 'debit']);
|
||||
const TRANSACTION_BOOKING_TYPES = new Set(['manual', 'invoice', 'adjustment', 'payment_claim']);
|
||||
const TRANSACTION_STATUSES = new Set(['planned', 'booked', 'cancelled']);
|
||||
|
||||
const ACCOUNT_TYPES = new Set(['bank', 'cash', 'virtual']);
|
||||
const ACCOUNT_USAGE_TYPES = new Set(['general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash']);
|
||||
@@ -44,6 +51,159 @@ function normalizePayload(payload = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTransactionPayload(payload = {}) {
|
||||
return {
|
||||
accountId: Number(payload.accountId) || null,
|
||||
invoiceId: Number(payload.invoiceId) || null,
|
||||
paymentClaimId: Number(payload.paymentClaimId) || null,
|
||||
direction: TRANSACTION_DIRECTIONS.has(payload.direction) ? payload.direction : 'credit',
|
||||
bookingType: TRANSACTION_BOOKING_TYPES.has(payload.bookingType) ? payload.bookingType : 'manual',
|
||||
status: TRANSACTION_STATUSES.has(payload.status) ? payload.status : 'booked',
|
||||
bookingDate: trimText(payload.bookingDate, 10),
|
||||
valueDate: trimText(payload.valueDate, 10),
|
||||
amountCents: Number.parseInt(payload.amountCents, 10) || 0,
|
||||
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
|
||||
reference: trimText(payload.reference, 255),
|
||||
notes: trimText(payload.notes),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractClaimIdFromReference(reference) {
|
||||
const normalized = normalizeSearchText(reference);
|
||||
const match = normalized.match(/(?:forderung|beitrag|claim)\s*(?:nr|nummer|no)?\s*(\d+)/);
|
||||
if (match) return Number(match[1]) || null;
|
||||
const hashMatch = normalized.match(/#\s*(\d+)/);
|
||||
if (hashMatch) return Number(hashMatch[1]) || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function transactionMatchesClaimReference(transactionText, claim, member) {
|
||||
if (!transactionText) return false;
|
||||
const tokens = [
|
||||
claim?.id ? `forderung ${claim.id}` : '',
|
||||
claim?.id ? `claim ${claim.id}` : '',
|
||||
member?.firstName || '',
|
||||
member?.lastName || '',
|
||||
member?.email || '',
|
||||
member?.sepaMandateReference || member?.sepa_mandate_reference || '',
|
||||
member?.memberNumber || member?.member_number || '',
|
||||
claim?.notes || '',
|
||||
]
|
||||
.map(normalizeSearchText)
|
||||
.filter(Boolean);
|
||||
return tokens.some((token) => token && transactionText.includes(token));
|
||||
}
|
||||
|
||||
function scorePaymentClaimMatch(transaction, claim) {
|
||||
const text = normalizeSearchText([transaction.reference, transaction.notes].filter(Boolean).join(' '));
|
||||
if (!text) return 0;
|
||||
|
||||
let score = 0;
|
||||
const member = claim.member || {};
|
||||
const explicitClaimId = extractClaimIdFromReference(transaction.reference);
|
||||
if (explicitClaimId && Number(explicitClaimId) === Number(claim.id)) {
|
||||
score += 120;
|
||||
} else if (text.includes(`forderung ${claim.id}`) || text.includes(`claim ${claim.id}`) || text.includes(`beitrag ${claim.id}`)) {
|
||||
score += 90;
|
||||
}
|
||||
|
||||
if (transactionMatchesClaimReference(text, claim, member)) {
|
||||
score += 40;
|
||||
}
|
||||
|
||||
const amountCents = Number(transaction.amountCents || 0);
|
||||
const remainingAmountCents = Math.max(0, Number(claim.amountCents || 0) - Number(claim.paidAmountCents || 0));
|
||||
if (amountCents === remainingAmountCents) {
|
||||
score += 35;
|
||||
} else if (amountCents < remainingAmountCents) {
|
||||
score += 15;
|
||||
} else {
|
||||
score -= 20;
|
||||
}
|
||||
|
||||
const bookingDate = transaction.bookingDate ? new Date(transaction.bookingDate) : null;
|
||||
const dueDate = claim.dueOn ? new Date(claim.dueOn) : null;
|
||||
if (bookingDate && dueDate && !Number.isNaN(bookingDate.getTime()) && !Number.isNaN(dueDate.getTime())) {
|
||||
const dayDistance = Math.abs(Math.floor((bookingDate.getTime() - dueDate.getTime()) / 86400000));
|
||||
if (dayDistance <= 7) score += 20;
|
||||
else if (dayDistance <= 30) score += 10;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
async function findBestPaymentClaimMatch(clubId, transaction, dbTransaction = null) {
|
||||
if (!transaction || transaction.direction !== 'credit' || transaction.status !== 'booked' || Number(transaction.amountCents || 0) <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (transaction.invoiceId || transaction.paymentClaimId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitClaimId = extractClaimIdFromReference(transaction.reference);
|
||||
if (explicitClaimId) {
|
||||
const explicitClaim = await ClubPaymentClaim.findOne({
|
||||
where: {
|
||||
id: explicitClaimId,
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
},
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
if (explicitClaim && Number(transaction.amountCents || 0) <= Math.max(0, Number(explicitClaim.amountCents || 0) - Number(explicitClaim.paidAmountCents || 0))) {
|
||||
return explicitClaim;
|
||||
}
|
||||
}
|
||||
|
||||
const claims = await ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
currencyCode: transaction.currencyCode || 'EUR',
|
||||
},
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
order: [['dueOn', 'ASC'], ['updatedAt', 'ASC']],
|
||||
});
|
||||
|
||||
let bestMatch = null;
|
||||
let bestScore = 0;
|
||||
let secondScore = 0;
|
||||
for (const claim of claims) {
|
||||
const score = scorePaymentClaimMatch(transaction, claim);
|
||||
if (score > bestScore) {
|
||||
secondScore = bestScore;
|
||||
bestScore = score;
|
||||
bestMatch = claim;
|
||||
} else if (score > secondScore) {
|
||||
secondScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMatch) return null;
|
||||
if (bestScore < 60) return null;
|
||||
if (bestScore < secondScore + 20) return null;
|
||||
if (Number(transaction.amountCents || 0) > Math.max(0, Number(bestMatch.amountCents || 0) - Number(bestMatch.paidAmountCents || 0))) {
|
||||
return null;
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function validatePayload(payload) {
|
||||
if (!payload.name) {
|
||||
const error = new Error('Kontobezeichnung ist erforderlich.');
|
||||
@@ -70,6 +230,26 @@ function validatePayload(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
function validateTransactionPayload(payload) {
|
||||
if (!payload.accountId) {
|
||||
const error = new Error('Für eine Kontenbewegung muss ein Konto gewählt werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!payload.bookingDate) {
|
||||
const error = new Error('Buchungsdatum ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(Number(payload.amountCents)) || Number(payload.amountCents) <= 0) {
|
||||
const error = new Error('Der Betrag muss größer als 0 sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSingleDefault(clubId, accountId, transaction) {
|
||||
await ClubAccount.update(
|
||||
{ isDefault: false },
|
||||
@@ -112,13 +292,182 @@ async function ensureFallbackDefault(clubId, transaction) {
|
||||
|
||||
class ClubAccountService {
|
||||
async listClubAccounts(clubId) {
|
||||
return ClubAccount.findAll({
|
||||
where: { clubId },
|
||||
const [accounts, transactions] = await Promise.all([
|
||||
ClubAccount.findAll({
|
||||
where: { clubId },
|
||||
order: [
|
||||
['isDefault', 'DESC'],
|
||||
['status', 'ASC'],
|
||||
['sortOrder', 'ASC'],
|
||||
['name', 'ASC'],
|
||||
],
|
||||
}),
|
||||
ClubAccountTransaction.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
],
|
||||
order: [['bookingDate', 'DESC'], ['createdAt', 'DESC']],
|
||||
limit: 250,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
|
||||
async createTransaction(clubId, userId, payload) {
|
||||
const normalized = normalizeTransactionPayload(payload);
|
||||
validateTransactionPayload(normalized);
|
||||
|
||||
const account = await ClubAccount.findOne({
|
||||
where: { id: normalized.accountId, clubId },
|
||||
});
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sequelize.transaction(async (dbTransaction) => {
|
||||
let matchedClaim = null;
|
||||
if (normalized.paymentClaimId) {
|
||||
matchedClaim = await ClubPaymentClaim.findOne({
|
||||
where: {
|
||||
id: normalized.paymentClaimId,
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
},
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
transaction: dbTransaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
if (!matchedClaim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
matchedClaim = await findBestPaymentClaimMatch(clubId, normalized, dbTransaction);
|
||||
}
|
||||
|
||||
const transactionPayload = {
|
||||
clubId,
|
||||
createdByUserId: userId || null,
|
||||
...normalized,
|
||||
paymentClaimId: matchedClaim ? matchedClaim.id : normalized.paymentClaimId,
|
||||
bookingType: matchedClaim ? 'payment_claim' : normalized.bookingType,
|
||||
};
|
||||
|
||||
const transaction = await ClubAccountTransaction.create(transactionPayload, { transaction: dbTransaction });
|
||||
|
||||
if (matchedClaim) {
|
||||
await clubPaymentClaimService.applyPaymentToClaim(
|
||||
clubId,
|
||||
matchedClaim,
|
||||
{
|
||||
amountCents: Number(transaction.amountCents || 0),
|
||||
},
|
||||
dbTransaction
|
||||
);
|
||||
await clubPaymentClaimService.reconcileFromTransactions(clubId, matchedClaim.id, dbTransaction);
|
||||
}
|
||||
|
||||
return transaction.reload({
|
||||
include: [
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
],
|
||||
transaction: dbTransaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateTransaction(clubId, transactionId, payload) {
|
||||
const transactionRow = await ClubAccountTransaction.findOne({
|
||||
where: { id: transactionId, clubId },
|
||||
});
|
||||
if (!transactionRow) {
|
||||
const error = new Error('Kontenbewegung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (transactionRow.bookingType !== 'manual') {
|
||||
const error = new Error('Automatisch erzeugte Kontenbewegungen können nicht manuell bearbeitet werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeTransactionPayload(payload);
|
||||
validateTransactionPayload(normalized);
|
||||
const previousPaymentClaimId = Number(transactionRow.paymentClaimId || 0) || null;
|
||||
|
||||
const account = await ClubAccount.findOne({
|
||||
where: { id: normalized.accountId, clubId },
|
||||
});
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await transactionRow.update(normalized);
|
||||
|
||||
const claimIdsToReconcile = new Set([previousPaymentClaimId, normalized.paymentClaimId || null].filter(Boolean));
|
||||
for (const claimId of claimIdsToReconcile) {
|
||||
await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId);
|
||||
}
|
||||
|
||||
return transactionRow.reload({
|
||||
include: [
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTransaction(clubId, transactionId) {
|
||||
const transactionRow = await ClubAccountTransaction.findOne({
|
||||
where: { id: transactionId, clubId },
|
||||
});
|
||||
if (!transactionRow) {
|
||||
const error = new Error('Kontenbewegung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (transactionRow.bookingType !== 'manual') {
|
||||
const error = new Error('Automatisch erzeugte Kontenbewegungen können nicht gelöscht werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const claimId = Number(transactionRow.paymentClaimId || 0) || null;
|
||||
await transactionRow.destroy();
|
||||
if (claimId) {
|
||||
await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async listAccountTransactions(clubId, accountId = null) {
|
||||
const where = { clubId };
|
||||
if (accountId) {
|
||||
where.accountId = accountId;
|
||||
}
|
||||
|
||||
return ClubAccountTransaction.findAll({
|
||||
where,
|
||||
include: [
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
],
|
||||
order: [
|
||||
['isDefault', 'DESC'],
|
||||
['status', 'ASC'],
|
||||
['sortOrder', 'ASC'],
|
||||
['name', 'ASC'],
|
||||
['bookingDate', 'DESC'],
|
||||
['createdAt', 'DESC'],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
828
backend/services/clubCommunicationService.js
Normal file
828
backend/services/clubCommunicationService.js
Normal file
@@ -0,0 +1,828 @@
|
||||
import sequelize from '../database.js';
|
||||
import {
|
||||
ClubCommunicationThread,
|
||||
ClubCommunicationMessage,
|
||||
ClubCommunicationRecipient,
|
||||
ClubCommunicationDeliveryLog,
|
||||
ClubCommunicationTemplate,
|
||||
ClubDistributionGroup,
|
||||
ClubDistributionGroupMember,
|
||||
ClubSepaMandate,
|
||||
Member,
|
||||
MemberContact,
|
||||
User,
|
||||
} from '../models/index.js';
|
||||
import { sendClubCommunicationEmail } from './emailService.js';
|
||||
|
||||
const THREAD_TYPES = new Set(['direct', 'group', 'broadcast']);
|
||||
const THREAD_STATUSES = new Set(['draft', 'scheduled', 'sent', 'archived']);
|
||||
const MESSAGE_TYPES = new Set(['message', 'note']);
|
||||
const DIRECTIONS = new Set(['outbound', 'internal', 'inbound']);
|
||||
const GROUP_TYPES = new Set(['manual', 'training_group', 'team', 'custom']);
|
||||
|
||||
function trimText(value, maxLength = null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return null;
|
||||
return maxLength ? normalized.slice(0, maxLength) : normalized;
|
||||
}
|
||||
|
||||
function normalizeDate(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeId(value) {
|
||||
const numeric = Number(value);
|
||||
return Number.isInteger(numeric) && numeric > 0 ? numeric : null;
|
||||
}
|
||||
|
||||
function normalizeThreadPayload(payload = {}) {
|
||||
return {
|
||||
threadType: THREAD_TYPES.has(payload.threadType) ? payload.threadType : 'direct',
|
||||
subject: trimText(payload.subject, 255),
|
||||
status: THREAD_STATUSES.has(payload.status) ? payload.status : 'draft',
|
||||
recipientMemberId: normalizeId(payload.recipientMemberId),
|
||||
distributionGroupId: normalizeId(payload.distributionGroupId),
|
||||
scheduledAt: normalizeDate(payload.scheduledAt),
|
||||
recipientFilters: normalizeRecipientFilters(payload.recipientFilters),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGroupPayload(payload = {}) {
|
||||
const memberIds = Array.isArray(payload.memberIds)
|
||||
? [...new Set(payload.memberIds.map(normalizeId).filter(Boolean))]
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: trimText(payload.name, 255),
|
||||
description: trimText(payload.description),
|
||||
groupType: GROUP_TYPES.has(payload.groupType) ? payload.groupType : 'custom',
|
||||
memberIds,
|
||||
filterDefinition: normalizeRecipientFilters(payload.filterDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessagePayload(payload = {}) {
|
||||
return {
|
||||
body: trimText(payload.body),
|
||||
messageType: MESSAGE_TYPES.has(payload.messageType) ? payload.messageType : 'message',
|
||||
direction: DIRECTIONS.has(payload.direction) ? payload.direction : 'outbound',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRecipientFilters(payload = {}) {
|
||||
return {
|
||||
activeOnly: payload?.activeOnly !== false,
|
||||
onlyWithEmail: Boolean(payload?.onlyWithEmail),
|
||||
missingEmail: Boolean(payload?.missingEmail),
|
||||
requiresSepaMandate: Boolean(payload?.requiresSepaMandate),
|
||||
missingSepaMandate: Boolean(payload?.missingSepaMandate),
|
||||
testMembersOnly: Boolean(payload?.testMembersOnly),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplatePayload(payload = {}) {
|
||||
return {
|
||||
name: trimText(payload.name, 160),
|
||||
category: trimText(payload.category, 64) || 'general',
|
||||
subjectTemplate: trimText(payload.subjectTemplate, 255),
|
||||
bodyTemplate: trimText(payload.bodyTemplate),
|
||||
variablesHint: trimText(payload.variablesHint),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureThreadTargets(payload) {
|
||||
if (!payload.subject) {
|
||||
const error = new Error('Betreff ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (payload.threadType === 'direct' && !payload.recipientMemberId) {
|
||||
const error = new Error('Für Einzelnachrichten muss ein Mitglied ausgewählt werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (payload.threadType === 'group' && !payload.distributionGroupId) {
|
||||
const error = new Error('Für Gruppen-Nachrichten muss eine Verteilergruppe ausgewählt werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMemberBelongsToClub(clubId, memberId) {
|
||||
if (!memberId) return;
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId } });
|
||||
if (!member) {
|
||||
const error = new Error('Mitglied wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGroupBelongsToClub(clubId, groupId) {
|
||||
if (!groupId) return null;
|
||||
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
|
||||
if (!group) {
|
||||
const error = new Error('Verteilergruppe wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function getMemberDisplayName(member) {
|
||||
return [member?.firstName, member?.lastName].filter(Boolean).join(' ').trim() || `Mitglied ${member?.id || ''}`.trim();
|
||||
}
|
||||
|
||||
function getPrimaryEmail(member) {
|
||||
const contacts = Array.isArray(member?.contacts) ? member.contacts : [];
|
||||
const emailContacts = contacts.filter((contact) => contact?.type === 'email' && contact?.value);
|
||||
const primaryContact = emailContacts.find((contact) => contact.isPrimary) || emailContacts[0];
|
||||
return primaryContact?.value || member?.email || null;
|
||||
}
|
||||
|
||||
function applyRecipientFilters(members, filters = {}, memberIdsWithMandate = new Set()) {
|
||||
const normalized = normalizeRecipientFilters(filters);
|
||||
return (Array.isArray(members) ? members : []).filter((member) => {
|
||||
if (normalized.activeOnly && member?.active === false) return false;
|
||||
if (normalized.testMembersOnly && !member?.testMembership) return false;
|
||||
|
||||
const email = trimText(getPrimaryEmail(member), 255);
|
||||
if (normalized.onlyWithEmail && !email) return false;
|
||||
if (normalized.missingEmail && email) return false;
|
||||
|
||||
const hasMandate = memberIdsWithMandate.has(Number(member?.id));
|
||||
if (normalized.requiresSepaMandate && !hasMandate) return false;
|
||||
if (normalized.missingSepaMandate && hasMandate) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadActiveSepaMemberIds(clubId) {
|
||||
const mandates = await ClubSepaMandate.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active',
|
||||
revokedAt: null,
|
||||
},
|
||||
attributes: ['memberId'],
|
||||
});
|
||||
|
||||
return new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function looksLikeHtml(value = '') {
|
||||
return /<\/?[a-z][\s\S]*>/i.test(String(value || ''));
|
||||
}
|
||||
|
||||
function stripHtml(value = '') {
|
||||
return String(value || '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/p>\s*<p>/gi, '\n\n')
|
||||
.replace(/<\/div>\s*<div>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '\n- ')
|
||||
.replace(/<\/(p|div|li|h[1-6]|blockquote)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderMessageText(body) {
|
||||
if (looksLikeHtml(body)) {
|
||||
return stripHtml(body);
|
||||
}
|
||||
return String(body || '').trim();
|
||||
}
|
||||
|
||||
function renderMessageHtml(body) {
|
||||
if (looksLikeHtml(body)) {
|
||||
return `<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#1f2937;">${String(body || '')}</div>`;
|
||||
}
|
||||
return `<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#1f2937;white-space:pre-wrap;">${escapeHtml(body).replace(/\n/g, '<br>')}</div>`;
|
||||
}
|
||||
|
||||
function getLatestOutboundMessage(messages = []) {
|
||||
const outboundMessages = messages.filter((message) => message?.messageType === 'message' && message?.direction === 'outbound');
|
||||
return outboundMessages.at(-1) || null;
|
||||
}
|
||||
|
||||
function classifyDeliveryError(error) {
|
||||
const responseCode = Number(error?.responseCode || error?.response?.statusCode || 0) || null;
|
||||
const code = String(error?.code || '').trim() || `SMTP_${responseCode || 'UNKNOWN'}`;
|
||||
const message = trimText(error?.message, 500) || 'Unbekannter Versandfehler.';
|
||||
const retryableCodes = new Set([
|
||||
'ETIMEDOUT',
|
||||
'ESOCKET',
|
||||
'ECONNECTION',
|
||||
'ECONNRESET',
|
||||
'EAI_AGAIN',
|
||||
'ENOTFOUND',
|
||||
'EMESSAGE',
|
||||
'EMAIL_CONFIG_MISSING',
|
||||
]);
|
||||
const retryableResponseCodes = new Set([421, 425, 429, 450, 451, 452]);
|
||||
const retryable = retryableCodes.has(code) || (responseCode ? retryableResponseCodes.has(responseCode) : false);
|
||||
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
responseCode,
|
||||
};
|
||||
}
|
||||
|
||||
function canAttemptDelivery(recipient) {
|
||||
const hasEmail = !!trimText(recipient?.emailSnapshot, 255);
|
||||
if (!hasEmail) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Keine E-Mail-Adresse hinterlegt.',
|
||||
retryable: false,
|
||||
code: 'MISSING_EMAIL',
|
||||
};
|
||||
}
|
||||
|
||||
if (recipient?.deliveryStatus === 'sent') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Empfänger wurde bereits erfolgreich beliefert.',
|
||||
retryable: false,
|
||||
code: 'ALREADY_SENT',
|
||||
};
|
||||
}
|
||||
|
||||
if (recipient?.deliveryStatus === 'failed' && recipient?.retryable === false) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: recipient?.errorMessage || 'Versandfehler ist nicht erneut versendbar.',
|
||||
retryable: false,
|
||||
code: recipient?.errorCode || 'NON_RETRYABLE_FAILURE',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
reason: '',
|
||||
retryable: true,
|
||||
code: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildRecipientRowsForThread(clubId, threadPayload) {
|
||||
let members = [];
|
||||
const memberIdsWithMandate = await loadActiveSepaMemberIds(clubId);
|
||||
|
||||
if (threadPayload.threadType === 'direct' && threadPayload.recipientMemberId) {
|
||||
members = await Member.findAll({
|
||||
where: { clubId, id: threadPayload.recipientMemberId },
|
||||
include: [{ model: MemberContact, as: 'contacts', required: false }],
|
||||
});
|
||||
} else if (threadPayload.threadType === 'group' && threadPayload.distributionGroupId) {
|
||||
const group = await ClubDistributionGroup.findOne({
|
||||
where: { id: threadPayload.distributionGroupId, clubId },
|
||||
});
|
||||
const memberships = await ClubDistributionGroupMember.findAll({
|
||||
where: { groupId: threadPayload.distributionGroupId },
|
||||
include: [{
|
||||
model: Member,
|
||||
as: 'member',
|
||||
required: true,
|
||||
where: { clubId, active: true },
|
||||
include: [{ model: MemberContact, as: 'contacts', required: false }],
|
||||
}],
|
||||
});
|
||||
members = applyRecipientFilters(
|
||||
memberships.map((membership) => membership.member).filter(Boolean),
|
||||
threadPayload.recipientFilters || group?.filterDefinition || {},
|
||||
memberIdsWithMandate
|
||||
);
|
||||
} else if (threadPayload.threadType === 'broadcast') {
|
||||
members = await Member.findAll({
|
||||
where: { clubId, active: true },
|
||||
include: [{ model: MemberContact, as: 'contacts', required: false }],
|
||||
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
|
||||
});
|
||||
members = applyRecipientFilters(members, threadPayload.recipientFilters || {}, memberIdsWithMandate);
|
||||
}
|
||||
|
||||
const uniqueMembers = new Map();
|
||||
members.forEach((member) => {
|
||||
const memberId = Number(member.id);
|
||||
if (!uniqueMembers.has(memberId)) {
|
||||
uniqueMembers.set(memberId, member);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueMembers.values()).map((member) => {
|
||||
const emailSnapshot = getPrimaryEmail(member);
|
||||
return {
|
||||
clubId,
|
||||
memberId: Number(member.id),
|
||||
recipientName: getMemberDisplayName(member),
|
||||
emailSnapshot: emailSnapshot || null,
|
||||
deliveryStatus: emailSnapshot ? 'pending' : 'failed',
|
||||
deliveredAt: null,
|
||||
lastAttemptAt: null,
|
||||
attemptCount: 0,
|
||||
retryable: false,
|
||||
errorCode: emailSnapshot ? null : 'MISSING_EMAIL',
|
||||
transportMessageId: null,
|
||||
errorMessage: emailSnapshot ? null : 'Keine E-Mail-Adresse hinterlegt.',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function replaceRecipientsForThread(thread, threadPayload, transaction) {
|
||||
const recipients = await buildRecipientRowsForThread(thread.clubId, threadPayload);
|
||||
await ClubCommunicationDeliveryLog.destroy({
|
||||
where: { threadId: thread.id, clubId: thread.clubId },
|
||||
transaction,
|
||||
});
|
||||
await ClubCommunicationRecipient.destroy({
|
||||
where: { threadId: thread.id, clubId: thread.clubId },
|
||||
transaction,
|
||||
});
|
||||
if (recipients.length > 0) {
|
||||
await ClubCommunicationRecipient.bulkCreate(
|
||||
recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
threadId: thread.id,
|
||||
})),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ClubCommunicationService {
|
||||
async listClubCommunication(clubId) {
|
||||
const [threads, groups, members, templates] = await Promise.all([
|
||||
ClubCommunicationThread.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{ model: ClubCommunicationMessage, as: 'messages', required: false, include: [{ model: User, as: 'createdByUser', required: false }] },
|
||||
{ model: ClubCommunicationRecipient, as: 'recipients', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
{
|
||||
model: ClubCommunicationDeliveryLog,
|
||||
as: 'deliveryLogs',
|
||||
required: false,
|
||||
include: [
|
||||
{ model: ClubCommunicationRecipient, as: 'recipient', required: false },
|
||||
{ model: User, as: 'createdByUser', required: false },
|
||||
],
|
||||
},
|
||||
{ model: ClubDistributionGroup, as: 'distributionGroup', required: false },
|
||||
{ model: Member, as: 'recipientMember', required: false },
|
||||
{ model: User, as: 'createdByUser', required: false },
|
||||
],
|
||||
order: [
|
||||
['updatedAt', 'DESC'],
|
||||
[{ model: ClubCommunicationMessage, as: 'messages' }, 'createdAt', 'ASC'],
|
||||
[{ model: ClubCommunicationRecipient, as: 'recipients' }, 'recipientName', 'ASC'],
|
||||
[{ model: ClubCommunicationDeliveryLog, as: 'deliveryLogs' }, 'createdAt', 'DESC'],
|
||||
],
|
||||
}),
|
||||
ClubDistributionGroup.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{
|
||||
model: ClubDistributionGroupMember,
|
||||
as: 'memberships',
|
||||
required: false,
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
},
|
||||
],
|
||||
order: [['isSystemGroup', 'DESC'], ['name', 'ASC']],
|
||||
}),
|
||||
Member.findAll({
|
||||
where: { clubId, active: true },
|
||||
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
|
||||
}),
|
||||
ClubCommunicationTemplate.findAll({
|
||||
where: { clubId },
|
||||
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
|
||||
}),
|
||||
]);
|
||||
|
||||
return { threads, groups, members, templates };
|
||||
}
|
||||
|
||||
async createThread(clubId, userId, payload) {
|
||||
const normalized = normalizeThreadPayload(payload);
|
||||
ensureThreadTargets(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.recipientMemberId);
|
||||
await ensureGroupBelongsToClub(clubId, normalized.distributionGroupId);
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
const thread = await ClubCommunicationThread.create({
|
||||
clubId,
|
||||
createdByUserId: userId || null,
|
||||
...normalized,
|
||||
}, { transaction });
|
||||
await replaceRecipientsForThread(thread, normalized, transaction);
|
||||
return thread;
|
||||
});
|
||||
}
|
||||
|
||||
async updateThread(clubId, threadId, payload) {
|
||||
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeThreadPayload(payload);
|
||||
ensureThreadTargets(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.recipientMemberId);
|
||||
await ensureGroupBelongsToClub(clubId, normalized.distributionGroupId);
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await thread.update({
|
||||
...normalized,
|
||||
sentAt: normalized.status === 'sent' ? (thread.sentAt || new Date()) : (normalized.status === 'archived' ? thread.sentAt : null),
|
||||
}, { transaction });
|
||||
await replaceRecipientsForThread(thread, normalized, transaction);
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
|
||||
async addMessage(clubId, threadId, userId, payload) {
|
||||
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeMessagePayload(payload);
|
||||
if (!normalized.body) {
|
||||
const error = new Error('Nachrichtentext ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = await ClubCommunicationMessage.create({
|
||||
threadId: thread.id,
|
||||
clubId,
|
||||
createdByUserId: userId || null,
|
||||
...normalized,
|
||||
});
|
||||
|
||||
await thread.update({
|
||||
status: thread.status === 'archived' ? 'archived' : thread.status,
|
||||
sentAt: normalized.direction === 'outbound' && normalized.messageType === 'message' ? (thread.sentAt || new Date()) : thread.sentAt,
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async sendThread(clubId, threadId, userId = null) {
|
||||
const thread = await ClubCommunicationThread.findOne({
|
||||
where: { id: threadId, clubId },
|
||||
include: [
|
||||
{
|
||||
model: ClubCommunicationMessage,
|
||||
as: 'messages',
|
||||
required: false,
|
||||
order: [['createdAt', 'ASC']],
|
||||
},
|
||||
{
|
||||
model: ClubCommunicationRecipient,
|
||||
as: 'recipients',
|
||||
required: false,
|
||||
order: [['recipientName', 'ASC']],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const latestMessage = getLatestOutboundMessage(thread.messages || []);
|
||||
if (!latestMessage?.body) {
|
||||
const error = new Error('Zum Versand wird mindestens eine ausgehende Nachricht benötigt.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recipients = Array.isArray(thread.recipients) ? thread.recipients : [];
|
||||
let attemptedAny = false;
|
||||
let processedAny = false;
|
||||
|
||||
if (recipients.length === 0) {
|
||||
const error = new Error('Für diesen Kommunikationsvorgang sind keine Empfänger vorhanden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const eligibility = canAttemptDelivery(recipient);
|
||||
if (!eligibility.allowed) {
|
||||
if (!recipient.attemptCount && eligibility.code === 'MISSING_EMAIL') {
|
||||
const now = new Date();
|
||||
processedAny = true;
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await recipient.update({
|
||||
deliveryStatus: 'failed',
|
||||
lastAttemptAt: now,
|
||||
attemptCount: Number(recipient.attemptCount || 0) + 1,
|
||||
retryable: false,
|
||||
errorCode: eligibility.code,
|
||||
errorMessage: eligibility.reason,
|
||||
deliveredAt: null,
|
||||
transportMessageId: null,
|
||||
}, { transaction });
|
||||
|
||||
await ClubCommunicationDeliveryLog.create({
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
createdByUserId: userId || null,
|
||||
status: 'failed',
|
||||
attemptNo: Number(recipient.attemptCount || 0) + 1,
|
||||
retryable: false,
|
||||
errorCode: eligibility.code,
|
||||
errorMessage: eligibility.reason,
|
||||
}, { transaction });
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
attemptedAny = true;
|
||||
processedAny = true;
|
||||
const nextAttemptNo = Number(recipient.attemptCount || 0) + 1;
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const result = await sendClubCommunicationEmail({
|
||||
to: recipient.emailSnapshot,
|
||||
subject: thread.subject,
|
||||
text: renderMessageText(latestMessage.body),
|
||||
html: renderMessageHtml(latestMessage.body),
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await recipient.update({
|
||||
deliveryStatus: 'sent',
|
||||
deliveredAt: now,
|
||||
lastAttemptAt: now,
|
||||
attemptCount: nextAttemptNo,
|
||||
retryable: false,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
transportMessageId: trimText(result?.messageId, 255),
|
||||
}, { transaction });
|
||||
|
||||
await ClubCommunicationDeliveryLog.create({
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
createdByUserId: userId || null,
|
||||
status: 'sent',
|
||||
attemptNo: nextAttemptNo,
|
||||
retryable: false,
|
||||
transportMessageId: trimText(result?.messageId, 255),
|
||||
transportResponse: trimText(result?.response, 1000),
|
||||
}, { transaction });
|
||||
});
|
||||
} catch (sendError) {
|
||||
const classified = classifyDeliveryError(sendError);
|
||||
console.error('[sendClubCommunicationThread] delivery failed', {
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
email: recipient.emailSnapshot,
|
||||
code: classified.code,
|
||||
retryable: classified.retryable,
|
||||
message: classified.message,
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await recipient.update({
|
||||
deliveryStatus: 'failed',
|
||||
deliveredAt: null,
|
||||
lastAttemptAt: now,
|
||||
attemptCount: nextAttemptNo,
|
||||
retryable: classified.retryable,
|
||||
errorCode: classified.code,
|
||||
errorMessage: classified.message,
|
||||
transportMessageId: null,
|
||||
}, { transaction });
|
||||
|
||||
await ClubCommunicationDeliveryLog.create({
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
createdByUserId: userId || null,
|
||||
status: 'failed',
|
||||
attemptNo: nextAttemptNo,
|
||||
retryable: classified.retryable,
|
||||
errorCode: classified.code,
|
||||
errorMessage: classified.message,
|
||||
transportResponse: trimText(sendError?.response, 1000),
|
||||
}, { transaction });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!processedAny) {
|
||||
const error = new Error('Es gibt aktuell keine erneut versendbaren Empfänger.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await thread.update({
|
||||
status: 'sent',
|
||||
sentAt: thread.sentAt || new Date(),
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
|
||||
async deleteThread(clubId, threadId) {
|
||||
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await ClubCommunicationMessage.destroy({
|
||||
where: { threadId: thread.id, clubId },
|
||||
transaction,
|
||||
});
|
||||
await ClubCommunicationRecipient.destroy({
|
||||
where: { threadId: thread.id, clubId },
|
||||
transaction,
|
||||
});
|
||||
await ClubCommunicationDeliveryLog.destroy({
|
||||
where: { threadId: thread.id, clubId },
|
||||
transaction,
|
||||
});
|
||||
await thread.destroy({ transaction });
|
||||
});
|
||||
}
|
||||
|
||||
async createGroup(clubId, payload) {
|
||||
const normalized = normalizeGroupPayload(payload);
|
||||
if (!normalized.name) {
|
||||
const error = new Error('Gruppenname ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
const group = await ClubDistributionGroup.create({
|
||||
clubId,
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
groupType: normalized.groupType,
|
||||
filterDefinition: normalized.filterDefinition,
|
||||
}, { transaction });
|
||||
|
||||
if (normalized.memberIds.length > 0) {
|
||||
await ClubDistributionGroupMember.bulkCreate(
|
||||
normalized.memberIds.map((memberId) => ({ groupId: group.id, memberId })),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
return group;
|
||||
});
|
||||
}
|
||||
|
||||
async updateGroup(clubId, groupId, payload) {
|
||||
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
|
||||
if (!group) {
|
||||
const error = new Error('Verteilergruppe wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeGroupPayload(payload);
|
||||
if (!normalized.name) {
|
||||
const error = new Error('Gruppenname ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await group.update({
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
groupType: normalized.groupType,
|
||||
filterDefinition: normalized.filterDefinition,
|
||||
}, { transaction });
|
||||
|
||||
await ClubDistributionGroupMember.destroy({
|
||||
where: { groupId: group.id },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (normalized.memberIds.length > 0) {
|
||||
await ClubDistributionGroupMember.bulkCreate(
|
||||
normalized.memberIds.map((memberId) => ({ groupId: group.id, memberId })),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
async deleteGroup(clubId, groupId) {
|
||||
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
|
||||
if (!group) {
|
||||
const error = new Error('Verteilergruppe wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const linkedThreads = await ClubCommunicationThread.count({
|
||||
where: { clubId, distributionGroupId: group.id },
|
||||
});
|
||||
if (linkedThreads > 0) {
|
||||
const error = new Error('Verteilergruppe kann nicht gelöscht werden, weil noch Kommunikationsvorgänge darauf verweisen.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await ClubDistributionGroupMember.destroy({ where: { groupId: group.id }, transaction });
|
||||
await group.destroy({ transaction });
|
||||
});
|
||||
}
|
||||
|
||||
async createTemplate(clubId, payload) {
|
||||
const normalized = normalizeTemplatePayload(payload);
|
||||
if (!normalized.name || !normalized.bodyTemplate) {
|
||||
const error = new Error('Vorlagenname und Nachrichtentext sind erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return ClubCommunicationTemplate.create({
|
||||
clubId,
|
||||
...normalized,
|
||||
});
|
||||
}
|
||||
|
||||
async updateTemplate(clubId, templateId, payload) {
|
||||
const template = await ClubCommunicationTemplate.findOne({
|
||||
where: { id: templateId, clubId },
|
||||
});
|
||||
if (!template) {
|
||||
const error = new Error('Vorlage wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (template.isSystemTemplate) {
|
||||
const error = new Error('Systemvorlagen können nicht bearbeitet werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeTemplatePayload(payload);
|
||||
if (!normalized.name || !normalized.bodyTemplate) {
|
||||
const error = new Error('Vorlagenname und Nachrichtentext sind erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await template.update(normalized);
|
||||
return template;
|
||||
}
|
||||
|
||||
async deleteTemplate(clubId, templateId) {
|
||||
const template = await ClubCommunicationTemplate.findOne({
|
||||
where: { id: templateId, clubId },
|
||||
});
|
||||
if (!template) {
|
||||
const error = new Error('Vorlage wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (template.isSystemTemplate) {
|
||||
const error = new Error('Systemvorlagen können nicht gelöscht werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await template.destroy();
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubCommunicationService();
|
||||
361
backend/services/clubDocumentService.js
Normal file
361
backend/services/clubDocumentService.js
Normal file
@@ -0,0 +1,361 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import sequelize from '../database.js';
|
||||
import Club from '../models/Club.js';
|
||||
import ClubDocument from '../models/ClubDocument.js';
|
||||
import ClubDocumentVersion from '../models/ClubDocumentVersion.js';
|
||||
import ClubDocumentLink from '../models/ClubDocumentLink.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DOCUMENT_TYPES = new Set(['satzung', 'protokoll', 'nachweis', 'formular', 'vertrag', 'rechnung', 'other']);
|
||||
const DOCUMENT_STATUSES = new Set(['active', 'draft', 'archived', 'obsolete']);
|
||||
const VISIBILITY_SCOPES = new Set(['board', 'finance', 'trainers', 'all']);
|
||||
|
||||
function trimText(value, maxLength = null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return null;
|
||||
return maxLength ? normalized.slice(0, maxLength) : normalized;
|
||||
}
|
||||
|
||||
function normalizePayload(payload = {}) {
|
||||
return {
|
||||
documentType: DOCUMENT_TYPES.has(payload.documentType) ? payload.documentType : 'other',
|
||||
title: trimText(payload.title, 255),
|
||||
description: trimText(payload.description),
|
||||
status: DOCUMENT_STATUSES.has(payload.status) ? payload.status : 'active',
|
||||
visibilityScope: VISIBILITY_SCOPES.has(payload.visibilityScope) ? payload.visibilityScope : 'board',
|
||||
changeNote: trimText(payload.changeNote),
|
||||
linkedEntityType: trimText(payload.linkedEntityType, 32),
|
||||
linkedEntityId: payload.linkedEntityId ? Number(payload.linkedEntityId) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileName(value = '') {
|
||||
return String(value || 'document').replace(/[^a-zA-Z0-9._-]+/g, '_');
|
||||
}
|
||||
|
||||
function getStorageDir(clubId) {
|
||||
return path.join(__dirname, '..', 'uploads', 'club-documents', String(clubId));
|
||||
}
|
||||
|
||||
function ensureStorageDir(clubId) {
|
||||
const uploadDir = getStorageDir(clubId);
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
return uploadDir;
|
||||
}
|
||||
|
||||
function checksumFile(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
hash.update(fileBuffer);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function moveUploadedFile(tempPath, destinationPath) {
|
||||
try {
|
||||
fs.renameSync(tempPath, destinationPath);
|
||||
} catch (error) {
|
||||
if (error.code === 'EXDEV') {
|
||||
fs.copyFileSync(tempPath, destinationPath);
|
||||
fs.unlinkSync(tempPath);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDocumentSnapshot(document, versions = [], links = []) {
|
||||
const orderedVersions = [...versions].sort((a, b) => Number(b.versionNo || 0) - Number(a.versionNo || 0));
|
||||
const latestVersion = orderedVersions[0] || null;
|
||||
return {
|
||||
...document.get({ plain: true }),
|
||||
versionCount: versions.length,
|
||||
latestVersion: latestVersion ? latestVersion.get({ plain: true }) : null,
|
||||
versions: orderedVersions.map((version) => version.get({ plain: true })),
|
||||
links: links.map((link) => link.get({ plain: true })),
|
||||
linkedEntityCount: links.length,
|
||||
};
|
||||
}
|
||||
|
||||
class ClubDocumentService {
|
||||
async listClubDocuments(clubId, filters = {}) {
|
||||
const [club, documents] = await Promise.all([
|
||||
Club.findByPk(clubId, { attributes: ['id'] }),
|
||||
ClubDocument.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{ model: ClubDocumentVersion, as: 'versions', required: false },
|
||||
{ model: ClubDocumentLink, as: 'links', required: false },
|
||||
],
|
||||
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!club) {
|
||||
const error = new Error('Verein wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const search = trimText(filters.search)?.toLowerCase() || '';
|
||||
const typeFilter = trimText(filters.documentType);
|
||||
const statusFilter = trimText(filters.status);
|
||||
const visibilityFilter = trimText(filters.visibilityScope);
|
||||
|
||||
return documents
|
||||
.filter((document) => {
|
||||
if (typeFilter && document.documentType !== typeFilter) return false;
|
||||
if (statusFilter && document.status !== statusFilter) return false;
|
||||
if (visibilityFilter && document.visibilityScope !== visibilityFilter) return false;
|
||||
if (!search) return true;
|
||||
|
||||
const haystack = [
|
||||
document.title,
|
||||
document.description,
|
||||
document.documentType,
|
||||
document.status,
|
||||
document.visibilityScope,
|
||||
...(document.versions || []).map((version) => version.fileName),
|
||||
...(document.links || []).map((link) => `${link.linkedEntityType} ${link.linkedEntityId}`),
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
return haystack.includes(search);
|
||||
})
|
||||
.map((document) => buildDocumentSnapshot(document, document.versions || [], document.links || []));
|
||||
}
|
||||
|
||||
async createClubDocument(clubId, userId, payload = {}, file = null) {
|
||||
const normalized = normalizePayload(payload);
|
||||
if (!normalized.title) {
|
||||
const error = new Error('Bitte einen Dokumenttitel angeben.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!file) {
|
||||
const error = new Error('Eine Datei ist für das neue Dokument erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const club = await Club.findByPk(clubId, { attributes: ['id'] });
|
||||
if (!club) {
|
||||
const error = new Error('Verein wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const uploadDir = ensureStorageDir(clubId);
|
||||
const safeFileName = normalizeFileName(file.originalname);
|
||||
const storageFileName = `${Date.now()}_${safeFileName}`;
|
||||
const storagePath = path.join(uploadDir, storageFileName);
|
||||
moveUploadedFile(file.path, storagePath);
|
||||
|
||||
const checksum = checksumFile(storagePath);
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const document = await ClubDocument.create({
|
||||
clubId,
|
||||
documentType: normalized.documentType,
|
||||
title: normalized.title,
|
||||
description: normalized.description,
|
||||
status: normalized.status,
|
||||
visibilityScope: normalized.visibilityScope,
|
||||
ownerUserId: userId || null,
|
||||
currentVersionNo: 1,
|
||||
}, { transaction });
|
||||
|
||||
const version = await ClubDocumentVersion.create({
|
||||
documentId: document.id,
|
||||
versionNo: 1,
|
||||
fileName: safeFileName,
|
||||
storagePath,
|
||||
mimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
checksumSha256: checksum,
|
||||
uploadedByUserId: userId || null,
|
||||
changeNote: normalized.changeNote,
|
||||
}, { transaction });
|
||||
|
||||
if (normalized.linkedEntityType && normalized.linkedEntityId) {
|
||||
await ClubDocumentLink.create({
|
||||
documentId: document.id,
|
||||
linkedEntityType: normalized.linkedEntityType,
|
||||
linkedEntityId: normalized.linkedEntityId,
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return this.getClubDocumentById(clubId, document.id);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
if (fs.existsSync(storagePath)) {
|
||||
fs.unlinkSync(storagePath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateClubDocument(clubId, documentId, userId, payload = {}, file = null) {
|
||||
const normalized = normalizePayload(payload);
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [
|
||||
{ model: ClubDocumentVersion, as: 'versions', required: false },
|
||||
{ model: ClubDocumentLink, as: 'links', required: false },
|
||||
],
|
||||
});
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
let uploadedStoragePath = null;
|
||||
try {
|
||||
await document.update({
|
||||
title: normalized.title || document.title,
|
||||
description: normalized.description,
|
||||
status: normalized.status,
|
||||
visibilityScope: normalized.visibilityScope,
|
||||
documentType: normalized.documentType,
|
||||
}, { transaction });
|
||||
|
||||
if (file) {
|
||||
const uploadDir = ensureStorageDir(clubId);
|
||||
const nextVersionNo = Number(document.currentVersionNo || 1) + 1;
|
||||
const safeFileName = normalizeFileName(file.originalname);
|
||||
const storageFileName = `${Date.now()}_v${nextVersionNo}_${safeFileName}`;
|
||||
const storagePath = path.join(uploadDir, storageFileName);
|
||||
moveUploadedFile(file.path, storagePath);
|
||||
uploadedStoragePath = storagePath;
|
||||
const checksum = checksumFile(storagePath);
|
||||
|
||||
await ClubDocumentVersion.create({
|
||||
documentId: document.id,
|
||||
versionNo: nextVersionNo,
|
||||
fileName: safeFileName,
|
||||
storagePath,
|
||||
mimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
checksumSha256: checksum,
|
||||
uploadedByUserId: userId || null,
|
||||
changeNote: normalized.changeNote,
|
||||
}, { transaction });
|
||||
|
||||
await document.update({
|
||||
currentVersionNo: nextVersionNo,
|
||||
archivedAt: normalized.status === 'archived' ? new Date() : null,
|
||||
status: normalized.status,
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
if (normalized.linkedEntityType && normalized.linkedEntityId) {
|
||||
await ClubDocumentLink.findOrCreate({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
linkedEntityType: normalized.linkedEntityType,
|
||||
linkedEntityId: normalized.linkedEntityId,
|
||||
},
|
||||
defaults: {
|
||||
documentId: document.id,
|
||||
linkedEntityType: normalized.linkedEntityType,
|
||||
linkedEntityId: normalized.linkedEntityId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return this.getClubDocumentById(clubId, document.id);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
if (uploadedStoragePath && fs.existsSync(uploadedStoragePath)) {
|
||||
fs.unlinkSync(uploadedStoragePath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async archiveClubDocument(clubId, documentId) {
|
||||
const document = await ClubDocument.findOne({ where: { id: documentId, clubId } });
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await document.update({
|
||||
status: 'archived',
|
||||
archivedAt: new Date(),
|
||||
});
|
||||
return this.getClubDocumentById(clubId, document.id);
|
||||
}
|
||||
|
||||
async deleteClubDocument(clubId, documentId) {
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [{ model: ClubDocumentVersion, as: 'versions', required: false }],
|
||||
});
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const version of document.versions || []) {
|
||||
if (version.storagePath && fs.existsSync(version.storagePath)) {
|
||||
fs.unlinkSync(version.storagePath);
|
||||
}
|
||||
}
|
||||
await ClubDocumentLink.destroy({ where: { documentId } });
|
||||
await ClubDocumentVersion.destroy({ where: { documentId } });
|
||||
await document.destroy();
|
||||
return true;
|
||||
}
|
||||
|
||||
async getClubDocumentById(clubId, documentId) {
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [
|
||||
{ model: ClubDocumentVersion, as: 'versions', required: false },
|
||||
{ model: ClubDocumentLink, as: 'links', required: false },
|
||||
],
|
||||
});
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
return buildDocumentSnapshot(document, document.versions || [], document.links || []);
|
||||
}
|
||||
|
||||
async getDocumentDownload(clubId, documentId, versionNo = null) {
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [{ model: ClubDocumentVersion, as: 'versions', required: false }],
|
||||
});
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const versions = document.versions || [];
|
||||
const selectedVersion = versionNo
|
||||
? versions.find((version) => Number(version.versionNo) === Number(versionNo))
|
||||
: [...versions].sort((a, b) => Number(b.versionNo || 0) - Number(a.versionNo || 0))[0];
|
||||
|
||||
if (!selectedVersion) {
|
||||
const error = new Error('Dokumentversion wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { document: document.get({ plain: true }), version: selectedVersion.get({ plain: true }) };
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubDocumentService();
|
||||
@@ -1,5 +1,6 @@
|
||||
import sequelize from '../database.js';
|
||||
import Club from '../models/Club.js';
|
||||
import ClubAccountTransaction from '../models/ClubAccountTransaction.js';
|
||||
import ClubInvoice from '../models/ClubInvoice.js';
|
||||
import ClubInvoiceItem from '../models/ClubInvoiceItem.js';
|
||||
import ClubInvoiceParty from '../models/ClubInvoiceParty.js';
|
||||
@@ -34,6 +35,10 @@ function normalizePartyPayload(payload = {}) {
|
||||
return {
|
||||
name: trimText(payload.name, 255),
|
||||
partyType: PARTY_TYPES.has(payload.partyType) ? payload.partyType : 'customer',
|
||||
status: trimText(payload.status, 32) || 'active',
|
||||
contractReference: trimText(payload.contractReference, 120),
|
||||
validFrom: normalizeDate(payload.validFrom),
|
||||
validTo: normalizeDate(payload.validTo),
|
||||
contactName: trimText(payload.contactName, 255),
|
||||
email: trimText(payload.email, 255),
|
||||
phone: trimText(payload.phone, 80),
|
||||
@@ -76,7 +81,6 @@ function normalizeInvoicePayload(payload = {}) {
|
||||
invoiceDirection: INVOICE_DIRECTIONS.has(payload.invoiceDirection) ? payload.invoiceDirection : 'outgoing',
|
||||
invoiceType: INVOICE_TYPES.has(payload.invoiceType) ? payload.invoiceType : 'other',
|
||||
status: INVOICE_STATUSES.has(payload.status) ? payload.status : 'draft',
|
||||
invoiceNumber: trimText(payload.invoiceNumber, 64),
|
||||
externalReference: trimText(payload.externalReference, 255),
|
||||
partyId: payload.partyId ? Number(payload.partyId) : null,
|
||||
accountId: payload.accountId ? Number(payload.accountId) : null,
|
||||
@@ -117,13 +121,73 @@ function summarizeItems(items) {
|
||||
}
|
||||
|
||||
function buildInvoiceNumber(prefix, nextNumber, referenceDate = new Date()) {
|
||||
const year = referenceDate.getFullYear();
|
||||
const parsedReferenceDate = referenceDate instanceof Date ? referenceDate : new Date(referenceDate);
|
||||
const year = Number.isNaN(parsedReferenceDate.getTime()) ? new Date().getFullYear() : parsedReferenceDate.getFullYear();
|
||||
const normalizedPrefix = String(prefix || '').trim().toUpperCase();
|
||||
const paddedCounter = String(Math.max(1, Number.parseInt(nextNumber, 10) || 1)).padStart(4, '0');
|
||||
return normalizedPrefix ? `${normalizedPrefix}-${year}-${paddedCounter}` : `${year}-${paddedCounter}`;
|
||||
}
|
||||
|
||||
async function generateNextInvoiceNumber(clubId, invoiceDirection, transaction) {
|
||||
function deriveInvoiceTransactionData(invoice) {
|
||||
if (!invoice?.accountId || invoice.status === 'draft') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const amountCents = Math.max(0, Number.parseInt(invoice.grossAmountCents, 10) || 0);
|
||||
if (!amountCents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bookingDate = invoice.paidOn || invoice.issuedOn || invoice.dueOn || new Date().toISOString().slice(0, 10);
|
||||
const status = invoice.status === 'paid'
|
||||
? 'booked'
|
||||
: ['issued', 'partially_paid'].includes(invoice.status)
|
||||
? 'planned'
|
||||
: 'cancelled';
|
||||
|
||||
return {
|
||||
clubId: invoice.clubId,
|
||||
accountId: invoice.accountId,
|
||||
invoiceId: invoice.id,
|
||||
direction: invoice.invoiceDirection === 'incoming' ? 'debit' : 'credit',
|
||||
bookingType: 'invoice',
|
||||
status,
|
||||
bookingDate,
|
||||
valueDate: bookingDate,
|
||||
amountCents,
|
||||
currencyCode: invoice.currencyCode || 'EUR',
|
||||
reference: invoice.invoiceNumber || invoice.externalReference || `Rechnung ${invoice.id}`,
|
||||
notes: invoice.description || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function syncInvoiceAccountTransaction(invoice, transaction) {
|
||||
const transactionData = deriveInvoiceTransactionData(invoice);
|
||||
const existingTransaction = await ClubAccountTransaction.findOne({
|
||||
where: {
|
||||
clubId: invoice.clubId,
|
||||
invoiceId: invoice.id,
|
||||
bookingType: 'invoice',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!transactionData) {
|
||||
if (existingTransaction) {
|
||||
await existingTransaction.destroy({ transaction });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existingTransaction) {
|
||||
await existingTransaction.update(transactionData, { transaction });
|
||||
return existingTransaction;
|
||||
}
|
||||
|
||||
return ClubAccountTransaction.create(transactionData, { transaction });
|
||||
}
|
||||
|
||||
async function generateNextInvoiceNumber(clubId, invoiceDirection, issuedOn, transaction) {
|
||||
const club = await Club.findByPk(clubId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
@@ -138,10 +202,22 @@ async function generateNextInvoiceNumber(clubId, invoiceDirection, transaction)
|
||||
const isIncoming = invoiceDirection === 'incoming';
|
||||
const prefixField = isIncoming ? 'incomingInvoicePrefix' : 'outgoingInvoicePrefix';
|
||||
const nextNumberField = isIncoming ? 'incomingInvoiceNextNumber' : 'outgoingInvoiceNextNumber';
|
||||
const invoiceNumber = buildInvoiceNumber(club[prefixField], club[nextNumberField]);
|
||||
const referenceDate = issuedOn || new Date();
|
||||
let nextNumber = Math.max(1, Number.parseInt(club[nextNumberField], 10) || 1);
|
||||
let invoiceNumber = buildInvoiceNumber(club[prefixField], nextNumber, referenceDate);
|
||||
|
||||
// Falls der Nummernkreis manuell zurückgesetzt wurde, wird bis zur nächsten freien Nummer weitergezählt.
|
||||
while (await ClubInvoice.count({
|
||||
where: { clubId, invoiceNumber },
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
})) {
|
||||
nextNumber += 1;
|
||||
invoiceNumber = buildInvoiceNumber(club[prefixField], nextNumber, referenceDate);
|
||||
}
|
||||
|
||||
await club.update({
|
||||
[nextNumberField]: Math.max(1, Number.parseInt(club[nextNumberField], 10) || 1) + 1,
|
||||
[nextNumberField]: nextNumber + 1,
|
||||
}, { transaction });
|
||||
|
||||
return invoiceNumber;
|
||||
@@ -240,8 +316,12 @@ class ClubInvoiceService {
|
||||
const totals = summarizeItems(normalized.items);
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
const invoiceNumber = normalized.invoiceNumber
|
||||
|| await generateNextInvoiceNumber(clubId, normalized.invoiceDirection, transaction);
|
||||
const invoiceNumber = await generateNextInvoiceNumber(
|
||||
clubId,
|
||||
normalized.invoiceDirection,
|
||||
normalized.issuedOn,
|
||||
transaction
|
||||
);
|
||||
|
||||
const invoice = await ClubInvoice.create({
|
||||
clubId,
|
||||
@@ -265,6 +345,8 @@ class ClubInvoiceService {
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await syncInvoiceAccountTransaction(invoice, transaction);
|
||||
|
||||
return ClubInvoice.findOne({
|
||||
where: { id: invoice.id, clubId },
|
||||
include: [
|
||||
@@ -290,8 +372,22 @@ class ClubInvoiceService {
|
||||
const totals = summarizeItems(normalized.items);
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
if (invoice.invoiceNumber && normalized.invoiceDirection !== invoice.invoiceDirection) {
|
||||
const error = new Error('Die Rechnungsrichtung kann nach Vergabe der Rechnungsnummer nicht mehr geändert werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const invoiceNumber = invoice.invoiceNumber || await generateNextInvoiceNumber(
|
||||
clubId,
|
||||
normalized.invoiceDirection,
|
||||
normalized.issuedOn || invoice.issuedOn,
|
||||
transaction
|
||||
);
|
||||
|
||||
await invoice.update({
|
||||
...normalized,
|
||||
invoiceNumber,
|
||||
...totals,
|
||||
archivedAt: normalized.status === 'archived' ? (invoice.archivedAt || new Date()) : null,
|
||||
}, { transaction });
|
||||
@@ -314,6 +410,8 @@ class ClubInvoiceService {
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await syncInvoiceAccountTransaction(invoice, transaction);
|
||||
|
||||
return ClubInvoice.findOne({
|
||||
where: { id: invoice.id, clubId },
|
||||
include: [
|
||||
@@ -340,13 +438,22 @@ class ClubInvoiceService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await invoice.update({
|
||||
status,
|
||||
archivedAt: status === 'archived' ? (invoice.archivedAt || new Date()) : null,
|
||||
paidOn: status === 'paid' ? (invoice.paidOn || new Date().toISOString().slice(0, 10)) : invoice.paidOn,
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await invoice.update({
|
||||
status,
|
||||
archivedAt: status === 'archived' ? (invoice.archivedAt || new Date()) : null,
|
||||
paidOn: status === 'paid' ? (invoice.paidOn || new Date().toISOString().slice(0, 10)) : invoice.paidOn,
|
||||
}, { transaction });
|
||||
await syncInvoiceAccountTransaction(invoice, transaction);
|
||||
});
|
||||
|
||||
return invoice;
|
||||
return invoice.reload({
|
||||
include: [
|
||||
{ model: ClubInvoiceParty, as: 'party', required: false },
|
||||
{ model: ClubInvoiceItem, as: 'items', required: false },
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInvoice(clubId, invoiceId) {
|
||||
@@ -358,6 +465,14 @@ class ClubInvoiceService {
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await ClubAccountTransaction.destroy({
|
||||
where: {
|
||||
clubId,
|
||||
invoiceId,
|
||||
bookingType: 'invoice',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
await ClubInvoiceItem.destroy({ where: { invoiceId }, transaction });
|
||||
await invoice.destroy({ transaction });
|
||||
});
|
||||
|
||||
409
backend/services/clubPaymentClaimService.js
Normal file
409
backend/services/clubPaymentClaimService.js
Normal file
@@ -0,0 +1,409 @@
|
||||
import { Op, Transaction } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import { ClubAccount, ClubAccountTransaction, ClubPaymentClaim, Member } from '../models/index.js';
|
||||
|
||||
const CLAIM_TYPES = new Set(['membership_fee', 'additional_fee', 'course_fee', 'penalty_fee', 'other']);
|
||||
const CLAIM_STATUSES = new Set(['open', 'partially_paid', 'paid', 'written_off', 'cancelled']);
|
||||
|
||||
function trimText(value, maxLength = null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return null;
|
||||
return maxLength ? normalized.slice(0, maxLength) : normalized;
|
||||
}
|
||||
|
||||
function normalizePayload(payload = {}) {
|
||||
return {
|
||||
memberId: Number(payload.memberId) || null,
|
||||
feeRuleId: Number(payload.feeRuleId) || null,
|
||||
claimType: CLAIM_TYPES.has(payload.claimType) ? payload.claimType : 'membership_fee',
|
||||
status: CLAIM_STATUSES.has(payload.status) ? payload.status : 'open',
|
||||
dueOn: trimText(payload.dueOn, 10),
|
||||
amountCents: Number.parseInt(payload.amountCents, 10) || 0,
|
||||
paidAmountCents: Math.max(0, Number.parseInt(payload.paidAmountCents, 10) || 0),
|
||||
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
|
||||
reminderLevel: Math.max(0, Number.parseInt(payload.reminderLevel, 10) || 0),
|
||||
notes: trimText(payload.notes),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReminderTimestamp(existingClaim, nextReminderLevel) {
|
||||
const previousReminderLevel = Math.max(0, Number.parseInt(existingClaim?.reminderLevel, 10) || 0);
|
||||
if (nextReminderLevel <= 0) return null;
|
||||
if (nextReminderLevel > previousReminderLevel) return new Date();
|
||||
return existingClaim?.lastReminderAt || new Date();
|
||||
}
|
||||
|
||||
function clampPaidAmount(amountCents, paidAmountCents) {
|
||||
return Math.max(0, Math.min(Number(amountCents || 0), Number(paidAmountCents || 0)));
|
||||
}
|
||||
|
||||
function deriveFinancialStatus(status, amountCents, paidAmountCents) {
|
||||
if (status === 'cancelled' || status === 'written_off') return status;
|
||||
if (Number(amountCents || 0) <= 0) return 'open';
|
||||
if (paidAmountCents >= amountCents) return 'paid';
|
||||
if (paidAmountCents > 0) return 'partially_paid';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function deriveStatusFromBalance(amountCents, paidAmountCents) {
|
||||
if (Number(amountCents || 0) <= 0) return 'open';
|
||||
if (paidAmountCents >= amountCents) return 'paid';
|
||||
if (paidAmountCents > 0) return 'partially_paid';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function validatePayload(payload) {
|
||||
if (!payload.memberId) {
|
||||
const error = new Error('Mitglied ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!payload.dueOn) {
|
||||
const error = new Error('Fälligkeitsdatum ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!Number.isFinite(Number(payload.amountCents)) || Number(payload.amountCents) <= 0) {
|
||||
const error = new Error('Der Betrag muss größer als 0 sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMemberBelongsToClub(clubId, memberId) {
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId } });
|
||||
if (!member) {
|
||||
const error = new Error('Mitglied wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return member;
|
||||
}
|
||||
|
||||
class ClubPaymentClaimService {
|
||||
async listClaims(clubId) {
|
||||
const claims = await ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
[Op.or]: [
|
||||
{ archivedAt: null },
|
||||
{ archivedAt: { [Op.is]: null } },
|
||||
],
|
||||
status: { [Op.notIn]: ['written_off', 'cancelled'] },
|
||||
},
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
order: [['dueOn', 'ASC'], ['updatedAt', 'DESC']],
|
||||
});
|
||||
|
||||
return { claims };
|
||||
}
|
||||
|
||||
async createClaim(clubId, payload) {
|
||||
const normalized = normalizePayload(payload);
|
||||
validatePayload(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.memberId);
|
||||
const paidAmountCents = clampPaidAmount(normalized.amountCents, normalized.paidAmountCents);
|
||||
const status = deriveFinancialStatus(normalized.status, normalized.amountCents, paidAmountCents);
|
||||
|
||||
const claim = await ClubPaymentClaim.create({
|
||||
clubId,
|
||||
...normalized,
|
||||
paidAmountCents,
|
||||
status,
|
||||
settledAt: status === 'paid' ? new Date() : null,
|
||||
lastPaidAt: paidAmountCents > 0 ? new Date() : null,
|
||||
lastReminderAt: normalized.reminderLevel > 0 ? new Date() : null,
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async updateClaim(clubId, claimId, payload) {
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizePayload(payload);
|
||||
validatePayload(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.memberId);
|
||||
const paidAmountCents = clampPaidAmount(normalized.amountCents, normalized.paidAmountCents);
|
||||
const status = deriveFinancialStatus(normalized.status, normalized.amountCents, paidAmountCents);
|
||||
const previousPaidAmount = Number(claim.paidAmountCents || 0);
|
||||
|
||||
await claim.update({
|
||||
...normalized,
|
||||
paidAmountCents,
|
||||
status,
|
||||
settledAt: status === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: paidAmountCents > previousPaidAmount ? new Date() : (paidAmountCents > 0 ? (claim.lastPaidAt || new Date()) : null),
|
||||
lastReminderAt: resolveReminderTimestamp(claim, normalized.reminderLevel),
|
||||
archivedAt: ['written_off', 'cancelled'].includes(status) ? (claim.archivedAt || new Date()) : null,
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async updateClaimStatus(clubId, claimId, status) {
|
||||
if (!CLAIM_STATUSES.has(status)) {
|
||||
const error = new Error('Ungültiger Zahlungsstatus.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await claim.update({
|
||||
status,
|
||||
paidAmountCents: status === 'paid' ? Number(claim.amountCents || 0) : Number(claim.paidAmountCents || 0),
|
||||
settledAt: status === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: status === 'paid' ? (claim.lastPaidAt || new Date()) : claim.lastPaidAt,
|
||||
archivedAt: ['written_off', 'cancelled'].includes(status) ? (claim.archivedAt || new Date()) : null,
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteClaim(clubId, claimId) {
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await claim.destroy();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async registerPayment(clubId, claimId, payload = {}) {
|
||||
const amountCents = Number.parseInt(payload.amountCents, 10) || 0;
|
||||
if (amountCents <= 0) {
|
||||
const error = new Error('Der Zahlungsbetrag muss größer als 0 sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (['cancelled', 'written_off', 'paid'].includes(claim.status)) {
|
||||
const error = new Error('Für diesen Status kann keine Teilzahlung mehr erfasst werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
const accountId = Number(payload.accountId) || null;
|
||||
let account = null;
|
||||
if (accountId) {
|
||||
account = await ClubAccount.findOne({ where: { id: accountId, clubId } });
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const nextPaidAmount = clampPaidAmount(claim.amountCents, Number(claim.paidAmountCents || 0) + amountCents);
|
||||
const actualPaymentAmountCents = nextPaidAmount - Number(claim.paidAmountCents || 0);
|
||||
if (actualPaymentAmountCents <= 0) {
|
||||
const error = new Error('Die Forderung ist bereits vollständig beglichen.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
const nextStatus = deriveFinancialStatus(claim.status, claim.amountCents, nextPaidAmount);
|
||||
const note = trimText(payload.note);
|
||||
const notePrefix = `Teilzahlung ${formatMoney(actualPaymentAmountCents, claim.currencyCode)} erfasst`;
|
||||
const appendedNotes = notePrefix
|
||||
? [claim.notes, `${notePrefix}${note ? `: ${note}` : ''}`].filter(Boolean).join('\n')
|
||||
: claim.notes;
|
||||
const bookingDate = trimText(payload.bookingDate, 10) || new Date().toISOString().slice(0, 10);
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await this.applyPaymentToClaim(clubId, claim, {
|
||||
amountCents: actualPaymentAmountCents,
|
||||
paidAmountCents: nextPaidAmount,
|
||||
status: nextStatus,
|
||||
notes: appendedNotes,
|
||||
}, transaction);
|
||||
|
||||
if (account) {
|
||||
await ClubAccountTransaction.create({
|
||||
clubId,
|
||||
accountId: account.id,
|
||||
paymentClaimId: claim.id,
|
||||
direction: 'credit',
|
||||
bookingType: 'payment_claim',
|
||||
status: 'booked',
|
||||
bookingDate,
|
||||
valueDate: bookingDate,
|
||||
amountCents: actualPaymentAmountCents,
|
||||
currencyCode: claim.currencyCode || account.currencyCode || 'EUR',
|
||||
reference: payload.reference ? trimText(payload.reference, 255) : `Forderung #${claim.id}`,
|
||||
notes: note || `Zahlung zu Forderung #${claim.id}`,
|
||||
}, { transaction });
|
||||
}
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async reconcileFromTransactions(clubId, claimId, dbTransaction = null) {
|
||||
const claim = await ClubPaymentClaim.findOne({
|
||||
where: { id: claimId, clubId },
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (['cancelled', 'written_off'].includes(claim.status)) {
|
||||
return claim;
|
||||
}
|
||||
|
||||
const transactions = await ClubAccountTransaction.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
paymentClaimId: claim.id,
|
||||
direction: 'credit',
|
||||
status: 'booked',
|
||||
},
|
||||
order: [['bookingDate', 'ASC'], ['createdAt', 'ASC']],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
|
||||
const paidAmountCents = transactions.reduce((sum, transaction) => sum + Number(transaction.amountCents || 0), 0);
|
||||
const latestPaymentTransaction = transactions[transactions.length - 1] || null;
|
||||
const nextStatus = deriveStatusFromBalance(Number(claim.amountCents || 0), paidAmountCents);
|
||||
|
||||
await claim.update({
|
||||
paidAmountCents,
|
||||
status: nextStatus,
|
||||
settledAt: nextStatus === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: paidAmountCents > 0
|
||||
? (latestPaymentTransaction?.bookingDate ? new Date(`${latestPaymentTransaction.bookingDate}T00:00:00`) : (claim.lastPaidAt || new Date()))
|
||||
: null,
|
||||
archivedAt: nextStatus === 'paid' ? null : claim.archivedAt,
|
||||
}, dbTransaction ? { transaction: dbTransaction } : undefined);
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
transaction: dbTransaction || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async applyPaymentToClaim(clubId, claimOrId, payment, dbTransaction = null) {
|
||||
const claimId = typeof claimOrId === 'object' && claimOrId ? claimOrId.id : claimOrId;
|
||||
const claim = typeof claimOrId === 'object' && claimOrId ? claimOrId : await ClubPaymentClaim.findOne({
|
||||
where: { id: claimId, clubId },
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (['cancelled', 'written_off', 'paid'].includes(claim.status)) {
|
||||
const error = new Error('Für diesen Status kann keine Zahlung mehr erfasst werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const incomingAmountCents = Number.parseInt(payment?.amountCents, 10) || 0;
|
||||
if (incomingAmountCents <= 0) {
|
||||
const error = new Error('Der Zahlungsbetrag muss größer als 0 sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const currentPaidAmount = Number(claim.paidAmountCents || 0);
|
||||
const nextPaidAmount = clampPaidAmount(claim.amountCents, payment.paidAmountCents != null
|
||||
? Number(payment.paidAmountCents)
|
||||
: currentPaidAmount + incomingAmountCents);
|
||||
const appliedAmountCents = nextPaidAmount - currentPaidAmount;
|
||||
if (appliedAmountCents <= 0) {
|
||||
const error = new Error('Die Forderung ist bereits vollständig beglichen.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextStatus = payment.status || deriveFinancialStatus(claim.status, claim.amountCents, nextPaidAmount);
|
||||
await claim.update({
|
||||
paidAmountCents: nextPaidAmount,
|
||||
status: nextStatus,
|
||||
settledAt: nextStatus === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: new Date(),
|
||||
notes: payment.notes != null ? payment.notes : claim.notes,
|
||||
lastReminderAt: payment.lastReminderAt != null ? payment.lastReminderAt : claim.lastReminderAt,
|
||||
}, dbTransaction ? { transaction: dbTransaction } : undefined);
|
||||
|
||||
return {
|
||||
claim: await claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
transaction: dbTransaction || undefined,
|
||||
}),
|
||||
appliedAmountCents,
|
||||
nextPaidAmount,
|
||||
nextStatus,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatMoney(amountCents, currencyCode = 'EUR') {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: currencyCode || 'EUR',
|
||||
}).format(Number(amountCents || 0) / 100);
|
||||
}
|
||||
|
||||
export default new ClubPaymentClaimService();
|
||||
@@ -75,6 +75,7 @@ class ClubService {
|
||||
countryCode,
|
||||
stateCode,
|
||||
memberDataQualityRequirements,
|
||||
feeRules,
|
||||
outgoingInvoicePrefix,
|
||||
outgoingInvoiceNextNumber,
|
||||
incomingInvoicePrefix,
|
||||
@@ -93,6 +94,9 @@ class ClubService {
|
||||
if (memberDataQualityRequirements !== undefined) {
|
||||
updates.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(memberDataQualityRequirements);
|
||||
}
|
||||
if (feeRules !== undefined) {
|
||||
updates.feeRules = this.normalizeFeeRules(feeRules);
|
||||
}
|
||||
if (outgoingInvoicePrefix !== undefined) updates.outgoingInvoicePrefix = this.normalizeInvoicePrefix(outgoingInvoicePrefix, 'RE');
|
||||
if (incomingInvoicePrefix !== undefined) updates.incomingInvoicePrefix = this.normalizeInvoicePrefix(incomingInvoicePrefix, 'EI');
|
||||
if (outgoingInvoiceNextNumber !== undefined) updates.outgoingInvoiceNextNumber = this.normalizeInvoiceNextNumber(outgoingInvoiceNextNumber);
|
||||
@@ -131,6 +135,35 @@ class ClubService {
|
||||
);
|
||||
}
|
||||
|
||||
normalizeFeeRules(rules) {
|
||||
if (!Array.isArray(rules)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rules
|
||||
.map((rule, index) => {
|
||||
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const code = String(rule.code || rule.contributionGroupCode || '').trim();
|
||||
const label = String(rule.label || '').trim();
|
||||
const amountCents = Number.parseInt(rule.amountCents ?? Math.round(Number(rule.amountEuro || 0) * 100), 10);
|
||||
const cycle = String(rule.cycle || 'monthly').trim().toLowerCase();
|
||||
const note = String(rule.note || '').trim();
|
||||
|
||||
return {
|
||||
id: String(rule.id || `${Date.now()}-${index}`),
|
||||
code,
|
||||
label,
|
||||
amountCents: Number.isFinite(amountCents) && amountCents >= 0 ? amountCents : 0,
|
||||
cycle: ['monthly', 'quarterly', 'half_yearly', 'yearly', 'one_time'].includes(cycle) ? cycle : 'monthly',
|
||||
note: note || null,
|
||||
};
|
||||
})
|
||||
.filter((rule) => rule && (rule.code || rule.label || rule.amountCents > 0));
|
||||
}
|
||||
|
||||
normalizeInvoicePrefix(prefix, fallback) {
|
||||
const normalized = String(prefix || fallback || '').trim().toUpperCase().slice(0, 24);
|
||||
return normalized || fallback;
|
||||
|
||||
@@ -194,27 +194,29 @@ class ClubStatisticsService {
|
||||
const today = new Date();
|
||||
for (const claim of paymentClaims) {
|
||||
const amount = Number(claim.amountCents || 0);
|
||||
const paidAmount = Math.max(0, Number(claim.paidAmountCents || 0));
|
||||
const remainingAmount = Math.max(0, amount - paidAmount);
|
||||
const dueDate = claim.dueOn ? new Date(claim.dueOn) : null;
|
||||
const monthKey = getMonthKey(claim.dueOn || claim.createdAt);
|
||||
const monthEntry = monthKey ? monthMap.get(monthKey) : null;
|
||||
|
||||
if (claim.status === 'paid') {
|
||||
paymentTotals.paidCount += 1;
|
||||
paymentTotals.paidAmountCents += amount;
|
||||
paymentTotals.paidAmountCents += paidAmount || amount;
|
||||
if (monthEntry) {
|
||||
monthEntry.claimPaidCount += 1;
|
||||
monthEntry.claimPaidAmountCents += amount;
|
||||
monthEntry.claimPaidAmountCents += paidAmount || amount;
|
||||
}
|
||||
} else if (['open', 'partially_paid'].includes(claim.status)) {
|
||||
paymentTotals.openCount += 1;
|
||||
paymentTotals.openAmountCents += amount;
|
||||
paymentTotals.openAmountCents += remainingAmount;
|
||||
if (dueDate && dueDate < today) {
|
||||
paymentTotals.overdueCount += 1;
|
||||
paymentTotals.overdueAmountCents += amount;
|
||||
paymentTotals.overdueAmountCents += remainingAmount;
|
||||
}
|
||||
if (monthEntry) {
|
||||
monthEntry.claimOpenCount += 1;
|
||||
monthEntry.claimOpenAmountCents += amount;
|
||||
monthEntry.claimOpenAmountCents += remainingAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Op } from 'sequelize';
|
||||
import {
|
||||
CalendarEvent,
|
||||
ClubCommunicationRecipient,
|
||||
ClubCommunicationThread,
|
||||
ClubDocument,
|
||||
ClubInvoiceParty,
|
||||
ClubInvoice,
|
||||
ClubPaymentClaim,
|
||||
ClubRequest,
|
||||
ClubSepaMandate,
|
||||
@@ -8,7 +13,7 @@ import {
|
||||
ClubTaskSuppression,
|
||||
Member,
|
||||
} from '../models/index.js';
|
||||
import { CLUB_TASK_DEFINITIONS, getClubTaskDefinitionMap } from './clubTaskDefinitions.js';
|
||||
import { CLUB_TASK_DEFINITIONS, CLUB_WORKFLOW_SOURCES, getClubTaskDefinitionMap } from './clubTaskDefinitions.js';
|
||||
|
||||
const definitionMap = getClubTaskDefinitionMap();
|
||||
|
||||
@@ -43,6 +48,14 @@ function derivePriority(days) {
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function formatReminderStage(reminderLevel) {
|
||||
const level = Math.max(0, Number.parseInt(reminderLevel, 10) || 0);
|
||||
if (level <= 0) return 'keine Mahnung';
|
||||
if (level === 1) return '1. Mahnung';
|
||||
if (level === 2) return '2. Mahnung';
|
||||
return 'letzte Mahnung';
|
||||
}
|
||||
|
||||
function personName(entity) {
|
||||
return [entity?.firstName, entity?.lastName].filter(Boolean).join(' ').trim() || entity?.email || 'Unbekannt';
|
||||
}
|
||||
@@ -111,10 +124,67 @@ function requestSuggestionFor(request, today) {
|
||||
};
|
||||
}
|
||||
|
||||
function documentSuggestionFor(document, today) {
|
||||
const updatedAt = document.updatedAt ? new Date(document.updatedAt) : today;
|
||||
const ageDays = Math.floor((today.getTime() - updatedAt.getTime()) / 86400000);
|
||||
const type = String(document.documentType || '').toLowerCase();
|
||||
const status = String(document.status || '').toLowerCase();
|
||||
|
||||
if (!['satzung', 'protokoll', 'nachweis'].includes(type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'archived' || status === 'obsolete') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reviewThresholdDays = {
|
||||
satzung: 365,
|
||||
protokoll: 90,
|
||||
nachweis: 30,
|
||||
}[type] || 90;
|
||||
|
||||
if (ageDays < reviewThresholdDays && status !== 'draft') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
taskType: 'document_review_required',
|
||||
title: `${type === 'satzung' ? 'Satzung' : type === 'protokoll' ? 'Protokoll' : 'Nachweis'} prüfen: ${document.title}`,
|
||||
description: 'Wichtige Vereinsdokumente vor Freigabe oder Archivierung prüfen und den aktuellen Stand bestätigen.',
|
||||
priority: type === 'satzung' ? 'high' : 'normal',
|
||||
dueAt: addDays(updatedAt, Math.min(14, reviewThresholdDays)),
|
||||
};
|
||||
}
|
||||
|
||||
function sponsorPartySuggestionFor(party, today) {
|
||||
const status = String(party.status || '').toLowerCase();
|
||||
const validTo = party.validTo ? new Date(party.validTo) : null;
|
||||
const daysLeft = validTo ? daysUntil(validTo, today) : null;
|
||||
|
||||
if (status !== 'active' || !validTo || Number.isNaN(validTo.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (daysLeft > 60) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
taskType: 'sponsor_contract_renewal',
|
||||
title: `Sponsoringvertrag von ${party.name} verlängern`,
|
||||
description: daysLeft <= 0
|
||||
? 'Der Sponsorvertrag ist bereits abgelaufen und sollte umgehend geklärt werden.'
|
||||
: `Der Sponsorvertrag läuft in ${daysLeft} Tagen aus und sollte rechtzeitig verlängert werden.`,
|
||||
priority: daysLeft <= 14 ? 'high' : 'normal',
|
||||
dueAt: validTo,
|
||||
};
|
||||
}
|
||||
|
||||
class ClubTaskAutomationService {
|
||||
async buildAutomationOverview(clubId) {
|
||||
const today = todayStart();
|
||||
const [currentTasks, requests, members, mandates, paymentClaims, events, suppressions] = await Promise.all([
|
||||
const [currentTasks, requests, members, mandates, paymentClaims, invoices, parties, documents, communicationRecipients, events, suppressions] = await Promise.all([
|
||||
ClubTask.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
@@ -152,6 +222,41 @@ class ClubTaskAutomationService {
|
||||
},
|
||||
order: [['dueOn', 'ASC']],
|
||||
}),
|
||||
ClubInvoice.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['issued', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
dueOn: { [Op.ne]: null },
|
||||
},
|
||||
order: [['dueOn', 'ASC']],
|
||||
}),
|
||||
ClubInvoiceParty.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
partyType: 'sponsor',
|
||||
},
|
||||
order: [['status', 'ASC'], ['validTo', 'ASC'], ['name', 'ASC']],
|
||||
}),
|
||||
ClubDocument.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
documentType: { [Op.in]: ['satzung', 'protokoll', 'nachweis'] },
|
||||
status: { [Op.in]: ['active', 'draft'] },
|
||||
},
|
||||
order: [['updatedAt', 'DESC']],
|
||||
}),
|
||||
ClubCommunicationRecipient.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
deliveryStatus: 'failed',
|
||||
retryable: true,
|
||||
},
|
||||
include: [
|
||||
{ model: ClubCommunicationThread, as: 'thread', required: false },
|
||||
],
|
||||
order: [['updatedAt', 'DESC']],
|
||||
}),
|
||||
CalendarEvent.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
@@ -217,6 +322,49 @@ class ClubTaskAutomationService {
|
||||
});
|
||||
}
|
||||
|
||||
for (const document of documents) {
|
||||
const suggestion = documentSuggestionFor(document, today);
|
||||
if (!suggestion) continue;
|
||||
const key = buildAutomationKey(suggestion.taskType, 'club_document', document.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
...suggestion,
|
||||
automationSource: 'club_documents',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([document.updatedAt, document.status, document.currentVersionNo, document.title]),
|
||||
sourceEntityType: 'club_document',
|
||||
sourceEntityId: document.id,
|
||||
sourceSnapshot: {
|
||||
documentType: document.documentType,
|
||||
status: document.status,
|
||||
title: document.title,
|
||||
currentVersionNo: document.currentVersionNo,
|
||||
archivedAt: document.archivedAt || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const party of parties) {
|
||||
const suggestion = sponsorPartySuggestionFor(party, today);
|
||||
if (!suggestion) continue;
|
||||
const key = buildAutomationKey(suggestion.taskType, 'club_invoice_party', party.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
...suggestion,
|
||||
automationSource: 'club_invoice_parties',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([party.updatedAt, party.status, party.validTo, party.contractReference]),
|
||||
sourceEntityType: 'club_invoice_party',
|
||||
sourceEntityId: party.id,
|
||||
sourceSnapshot: {
|
||||
partyName: party.name,
|
||||
status: party.status,
|
||||
contractReference: party.contractReference || null,
|
||||
validTo: party.validTo || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
const name = personName(member);
|
||||
if (!String(member.email || '').trim()) {
|
||||
@@ -279,6 +427,7 @@ class ClubTaskAutomationService {
|
||||
|
||||
for (const claim of paymentClaims) {
|
||||
const dueDate = claim.dueOn ? new Date(claim.dueOn) : today;
|
||||
const remainingAmountCents = Math.max(0, Number(claim.amountCents || 0) - Number(claim.paidAmountCents || 0));
|
||||
const claimTaskType = Number(claim.reminderLevel || 0) > 0
|
||||
? 'payment_claim_reminder'
|
||||
: daysUntil(dueDate, today) < 0
|
||||
@@ -290,16 +439,16 @@ class ClubTaskAutomationService {
|
||||
taskType: claimTaskType,
|
||||
title:
|
||||
claimTaskType === 'payment_claim_reminder'
|
||||
? `Mahnfall ${claim.id} prüfen`
|
||||
? `${formatReminderStage(claim.reminderLevel)} für Forderung ${claim.id} prüfen`
|
||||
: claimTaskType === 'payment_claim_overdue'
|
||||
? `Überfällige Zahlung ${claim.id} nachfassen`
|
||||
: `Fällige Zahlung ${claim.id} vorbereiten`,
|
||||
description:
|
||||
claimTaskType === 'payment_claim_reminder'
|
||||
? `Offene Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} mit bestehender Mahnstufe prüfen.`
|
||||
? `Offene Restforderung über ${(remainingAmountCents / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} mit ${formatReminderStage(claim.reminderLevel)} prüfen.`
|
||||
: claimTaskType === 'payment_claim_overdue'
|
||||
? `Überfällige Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} priorisiert nachverfolgen.`
|
||||
: `Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} vor Fälligkeit organisatorisch vorbereiten.`,
|
||||
? `Überfällige Restforderung über ${(remainingAmountCents / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} priorisiert nachverfolgen.`
|
||||
: `Restforderung über ${(remainingAmountCents / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} vor Fälligkeit organisatorisch vorbereiten.`,
|
||||
priority: derivePriority(daysUntil(dueDate, today)),
|
||||
dueAt: dueDate,
|
||||
automationSource: 'club_payment_claims',
|
||||
@@ -309,17 +458,77 @@ class ClubTaskAutomationService {
|
||||
sourceEntityId: claim.id,
|
||||
sourceSnapshot: {
|
||||
amountCents: Number(claim.amountCents),
|
||||
paidAmountCents: Number(claim.paidAmountCents || 0),
|
||||
remainingAmountCents,
|
||||
currencyCode: claim.currencyCode,
|
||||
dueOn: claim.dueOn,
|
||||
status: claim.status,
|
||||
reminderLevel: claim.reminderLevel,
|
||||
reminderStage: formatReminderStage(claim.reminderLevel),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const dueDate = invoice.dueOn ? new Date(invoice.dueOn) : today;
|
||||
const invoiceTaskType = invoice.invoiceDirection === 'incoming'
|
||||
? (daysUntil(dueDate, today) < 0 ? 'invoice_incoming_overdue' : 'invoice_incoming_due_soon')
|
||||
: (daysUntil(dueDate, today) < 0 ? 'invoice_outgoing_overdue' : 'invoice_outgoing_due_soon');
|
||||
const key = buildAutomationKey(invoiceTaskType, 'club_invoice', invoice.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
taskType: invoiceTaskType,
|
||||
title: `${invoice.invoiceDirection === 'incoming' ? 'Eingangsrechnung' : 'Ausgangsrechnung'} ${invoice.invoiceNumber || `#${invoice.id}`} prüfen`,
|
||||
description: invoice.invoiceDirection === 'incoming'
|
||||
? 'Offene Eingangsrechnung rechtzeitig für Zahlung oder Klärung vorbereiten.'
|
||||
: 'Offene Ausgangsrechnung auf Zahlungseingang und Erinnerung prüfen.',
|
||||
priority: derivePriority(daysUntil(dueDate, today)),
|
||||
dueAt: dueDate,
|
||||
automationSource: 'club_invoices',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([invoice.updatedAt, invoice.status, invoice.dueOn, invoice.grossAmountCents]),
|
||||
sourceEntityType: 'club_invoice',
|
||||
sourceEntityId: invoice.id,
|
||||
sourceSnapshot: {
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoiceDirection: invoice.invoiceDirection,
|
||||
amountCents: Number(invoice.grossAmountCents || 0),
|
||||
currencyCode: invoice.currencyCode || 'EUR',
|
||||
dueOn: invoice.dueOn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of communicationRecipients) {
|
||||
const key = buildAutomationKey('communication_delivery_retry', 'club_communication_recipient', recipient.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
taskType: 'communication_delivery_retry',
|
||||
title: `Versandfehler für ${recipient.recipientName || 'Empfänger'} prüfen`,
|
||||
description: recipient.errorMessage || 'Retry-fähiger Versandfehler in der Vereinskommunikation.',
|
||||
priority: 'high',
|
||||
dueAt: addDays(today, 1),
|
||||
automationSource: 'club_communication',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([recipient.updatedAt, recipient.errorCode, recipient.errorMessage]),
|
||||
sourceEntityType: 'club_communication_recipient',
|
||||
sourceEntityId: recipient.id,
|
||||
sourceSnapshot: {
|
||||
recipientName: recipient.recipientName,
|
||||
threadId: recipient.threadId,
|
||||
threadSubject: recipient.thread?.subject || null,
|
||||
errorMessage: recipient.errorMessage || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (['cancelled', 'done'].includes(String(event.status || ''))) {
|
||||
continue;
|
||||
}
|
||||
if (hasExistingSourceTask(currentTasks, 'calendar_event', event.id, 'calendar_events')) continue;
|
||||
const dueDate = event.startDate ? addDays(new Date(event.startDate), -3) : addDays(today, 7);
|
||||
const referenceDate = event.registrationDeadline || event.startDate;
|
||||
const dueDate = referenceDate ? addDays(new Date(referenceDate), -3) : addDays(today, 7);
|
||||
const eventTaskType = daysUntil(dueDate, today) <= 1 ? 'calendar_event_deadline_check' : 'calendar_event_prepare';
|
||||
const key = buildAutomationKey(eventTaskType, 'calendar_event', event.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
@@ -337,13 +546,17 @@ class ClubTaskAutomationService {
|
||||
dueAt: dueDate,
|
||||
automationSource: 'calendar_events',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([event.updatedAt, event.startDate, event.endDate, event.category]),
|
||||
suppressionToken: suggestionToken([event.updatedAt, event.startDate, event.endDate, event.registrationDeadline, event.status, event.eventType]),
|
||||
sourceEntityType: 'calendar_event',
|
||||
sourceEntityId: event.id,
|
||||
sourceSnapshot: {
|
||||
title: event.title,
|
||||
eventType: event.eventType || null,
|
||||
status: event.status || null,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
registrationDeadline: event.registrationDeadline || null,
|
||||
location: event.location || null,
|
||||
category: event.category || null,
|
||||
},
|
||||
});
|
||||
@@ -357,6 +570,7 @@ class ClubTaskAutomationService {
|
||||
|
||||
return {
|
||||
definitions: CLUB_TASK_DEFINITIONS,
|
||||
workflowSources: CLUB_WORKFLOW_SOURCES,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
@@ -477,6 +691,32 @@ class ClubTaskAutomationService {
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
case 'sponsoring_prepare_offer':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `Sponsoringangebot für ${person} vorbereiten`,
|
||||
description: 'Konkretes Sponsoringangebot auf Basis des Erstkontakts zusammenstellen.',
|
||||
priority: 'normal',
|
||||
dueAt: addDays(sourceDate, 2),
|
||||
automationSource: task.automationSource,
|
||||
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
|
||||
sourceEntityType: task.relatedEntityType,
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
case 'sponsoring_follow_up':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `Sponsoringnachfassen bei ${person}`,
|
||||
description: 'Nach Versand des Angebots Rückmeldung einholen und den nächsten Schritt abstimmen.',
|
||||
priority: 'normal',
|
||||
dueAt: addDays(sourceDate, 4),
|
||||
automationSource: task.automationSource,
|
||||
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
|
||||
sourceEntityType: task.relatedEntityType,
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,50 @@ export const CLUB_TASK_DEFINITIONS = [
|
||||
trigger: 'Sponsoringanfrage ist offen oder wartet auf Vereinsreaktion.',
|
||||
description: 'Erstkontakt zu Sponsoringanfragen strukturieren und den nächsten Gesprächstermin vorbereiten.',
|
||||
suggestedAction: 'Ansprechpartner festlegen und Antwort mit weiterem Vorgehen senden.',
|
||||
nextTaskTypes: ['sponsoring_prepare_offer'],
|
||||
},
|
||||
{
|
||||
key: 'sponsoring_prepare_offer',
|
||||
label: 'Sponsoringangebot vorbereiten',
|
||||
source: 'club_requests',
|
||||
category: 'Sponsoring',
|
||||
workflow: 'Sponsoring',
|
||||
trigger: 'Erstkontakt ist erfolgt und ein konkretes Angebot soll vorbereitet werden.',
|
||||
description: 'Sponsoringpaket, Leistungen und Konditionen für den nächsten Kontakt bündeln.',
|
||||
suggestedAction: 'Leistungen abstimmen und Angebot oder Mustervertrag vorbereiten.',
|
||||
nextTaskTypes: ['sponsoring_follow_up'],
|
||||
},
|
||||
{
|
||||
key: 'sponsoring_follow_up',
|
||||
label: 'Nach Sponsoringangebot nachfassen',
|
||||
source: 'club_requests',
|
||||
category: 'Sponsoring',
|
||||
workflow: 'Sponsoring',
|
||||
trigger: 'Sponsoringangebot wurde versendet und eine Rückmeldung steht noch aus.',
|
||||
description: 'Nachfassen, Rückfragen klären und bei Zusage den Übergang in die Rechnungsanlage vorbereiten.',
|
||||
suggestedAction: 'Rückmeldung einholen und Rechnungs- oder Vertragsstart anstoßen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'document_review_required',
|
||||
label: 'Dokument prüfen und freigeben',
|
||||
source: 'club_documents',
|
||||
category: 'Dokumente',
|
||||
workflow: 'Dokumentenpflege',
|
||||
trigger: 'Satzung, Protokoll oder Nachweis wartet auf Prüfung oder Aktualisierung.',
|
||||
description: 'Wichtige Vereinsdokumente nach Änderungen oder in regelmäßigen Abständen prüfen.',
|
||||
suggestedAction: 'Inhalt prüfen, Freigabe dokumentieren und Version sauber archivieren.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'sponsor_contract_renewal',
|
||||
label: 'Sponsoringvertrag verlängern',
|
||||
source: 'club_invoice_parties',
|
||||
category: 'Sponsoring',
|
||||
workflow: 'Sponsoring',
|
||||
trigger: 'Ein Sponsoringvertrag läuft bald aus.',
|
||||
description: 'Laufenden Sponsorvertrag rechtzeitig prüfen und die Verlängerung oder Nachverhandlung vorbereiten.',
|
||||
suggestedAction: 'Kontakt aufnehmen, Laufzeit abstimmen und Vertragsverlängerung vorbereiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
@@ -164,6 +208,61 @@ export const CLUB_TASK_DEFINITIONS = [
|
||||
suggestedAction: 'Mahnung, Rücksprache oder Teilzahlungsentscheidung vorbereiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'invoice_outgoing_due_soon',
|
||||
label: 'Ausgangsrechnung nachverfolgen',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Ausgangsrechnung ist gestellt und bald fällig.',
|
||||
description: 'Offene Ausgangsrechnung kurz vor Fälligkeit aktiv beobachten und Kontakt vorbereiten.',
|
||||
suggestedAction: 'Zahlungseingang prüfen oder Erinnerung vorbereiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'invoice_outgoing_overdue',
|
||||
label: 'Überfällige Ausgangsrechnung nachfassen',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Ausgangsrechnung ist überfällig und noch nicht bezahlt.',
|
||||
description: 'Überfällige Forderung im Vereinskontext verfolgen und weitere Schritte einleiten.',
|
||||
suggestedAction: 'Erinnerung senden, Sponsorenkontakt aufnehmen oder Klärung einleiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'invoice_incoming_due_soon',
|
||||
label: 'Eingangsrechnung einplanen',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Eingangsrechnung ist bald fällig.',
|
||||
description: 'Offene Eingangsrechnung rechtzeitig für Zahlung und Freigabe vorbereiten.',
|
||||
suggestedAction: 'Konto prüfen, Freigabe sichern und Zahlung einplanen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'invoice_incoming_overdue',
|
||||
label: 'Überfällige Eingangsrechnung klären',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Eingangsrechnung ist überfällig.',
|
||||
description: 'Überfällige Eingangsrechnung auf offenen Zahlungsbedarf oder Klärung prüfen.',
|
||||
suggestedAction: 'Zahlung veranlassen oder Lieferantenkontakt aufnehmen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'communication_delivery_retry',
|
||||
label: 'Versandfehler nachfassen',
|
||||
source: 'club_communication',
|
||||
category: 'Kommunikation',
|
||||
workflow: 'Versand',
|
||||
trigger: 'Kommunikationsvorgang hat retry-fähige Zustellfehler.',
|
||||
description: 'Fehlgeschlagene Zustellung prüfen, Empfängerdaten korrigieren oder erneuten Versand auslösen.',
|
||||
suggestedAction: 'Fehler prüfen, E-Mail korrigieren und erneut senden.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'calendar_event_prepare',
|
||||
label: 'Termin vorbereiten',
|
||||
@@ -188,6 +287,63 @@ export const CLUB_TASK_DEFINITIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const CLUB_WORKFLOW_SOURCES = [
|
||||
{
|
||||
key: 'club_requests',
|
||||
label: 'Anfragen',
|
||||
description: 'Kontakt-, Probe- und Mitgliedsanfragen erzeugen Aufgaben entlang des Aufnahme-Workflows.',
|
||||
examples: ['Kontaktanfrage beantworten', 'Probetraining organisieren', 'Mitgliedsanfrage prüfen'],
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
label: 'Mitgliederdaten',
|
||||
description: 'Fehlende Stammdaten wie E-Mail oder Geburtsdatum werden als Datenqualitäts-Aufgaben erkannt.',
|
||||
examples: ['E-Mail ergänzen', 'Geburtsdatum ergänzen'],
|
||||
},
|
||||
{
|
||||
key: 'club_sepa_mandates',
|
||||
label: 'SEPA-Mandate',
|
||||
description: 'Fehlende oder notwendige SEPA-Mandate werden als Finanz- und Onboarding-Aufgaben erzeugt.',
|
||||
examples: ['SEPA-Mandat einholen'],
|
||||
},
|
||||
{
|
||||
key: 'club_payment_claims',
|
||||
label: 'Forderungen',
|
||||
description: 'Offene, fällige und gemahnte Beitragsforderungen erzeugen Nachfass- und Mahnaufgaben.',
|
||||
examples: ['Fällige Zahlung vorbereiten', 'Mahnstufe prüfen'],
|
||||
},
|
||||
{
|
||||
key: 'club_invoices',
|
||||
label: 'Rechnungen',
|
||||
description: 'Offene Ausgangs- und Eingangsrechnungen erzeugen finanzielle Wiedervorlagen entlang der Fälligkeit.',
|
||||
examples: ['Ausgangsrechnung nachverfolgen', 'Eingangsrechnung einplanen'],
|
||||
},
|
||||
{
|
||||
key: 'club_documents',
|
||||
label: 'Dokumente',
|
||||
description: 'Satzungen, Protokolle und Nachweise erzeugen Prüf- und Freigabeaufgaben.',
|
||||
examples: ['Dokument prüfen und freigeben'],
|
||||
},
|
||||
{
|
||||
key: 'club_invoice_parties',
|
||||
label: 'Sponsoren',
|
||||
description: 'Laufende Sponsorenbeziehungen erzeugen Vertragsverlängerungen und Nachfassaufgaben.',
|
||||
examples: ['Sponsoringvertrag verlängern', 'Sponsoringangebot vorbereiten'],
|
||||
},
|
||||
{
|
||||
key: 'club_communication',
|
||||
label: 'Kommunikation',
|
||||
description: 'Retry-fähige Versandfehler werden als Kommunikationsaufgaben sichtbar gemacht.',
|
||||
examples: ['Versandfehler nachfassen'],
|
||||
},
|
||||
{
|
||||
key: 'calendar_events',
|
||||
label: 'Termine',
|
||||
description: 'Bevorstehende Termine und Fristen erzeugen organisatorische Vorbereitungsaufgaben.',
|
||||
examples: ['Termin vorbereiten', 'Terminfrist prüfen'],
|
||||
},
|
||||
];
|
||||
|
||||
export function getClubTaskDefinitionMap() {
|
||||
return CLUB_TASK_DEFINITIONS.reduce((accumulator, definition) => {
|
||||
accumulator[definition.key] = definition;
|
||||
|
||||
@@ -20,6 +20,10 @@ function completedRequestStateForTaskType(taskType) {
|
||||
return { status: 'converted', workflowStage: 'onboarding_completed', closedAt: new Date() };
|
||||
case 'request_sponsoring_reply':
|
||||
return { status: 'waiting', workflowStage: 'sponsoring_contacted' };
|
||||
case 'sponsoring_prepare_offer':
|
||||
return { status: 'waiting', workflowStage: 'sponsoring_offer_prepared' };
|
||||
case 'sponsoring_follow_up':
|
||||
return { status: 'waiting', workflowStage: 'sponsoring_followed_up' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'Gmail',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
let transporter = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (!process.env.EMAIL_USER || !process.env.EMAIL_PASS) {
|
||||
const error = new Error('E-Mail-Versand ist nicht konfiguriert.');
|
||||
error.code = 'EMAIL_CONFIG_MISSING';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
service: 'Gmail',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return transporter;
|
||||
}
|
||||
|
||||
function getDefaultFrom() {
|
||||
return process.env.EMAIL_FROM || process.env.EMAIL_USER;
|
||||
}
|
||||
|
||||
async function sendMail(mailOptions) {
|
||||
return getTransporter().sendMail({
|
||||
from: getDefaultFrom(),
|
||||
...mailOptions,
|
||||
});
|
||||
}
|
||||
|
||||
const sendActivationEmail = async (email, activationCode) => {
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Account Activation',
|
||||
text: `Activate your account by clicking the following link: ${process.env.BASE_URL}/activate/${activationCode}`,
|
||||
};
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
const sendPasswordResetEmail = async (email, resetToken) => {
|
||||
const resetLink = `${process.env.BASE_URL}/reset-password/${resetToken}`;
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Passwort zurücksetzen',
|
||||
html: `
|
||||
@@ -46,8 +68,7 @@ const sendPasswordResetEmail = async (email, resetToken) => {
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
const sendFriendlyMatchInvitationEmail = async ({
|
||||
@@ -68,8 +89,7 @@ const sendFriendlyMatchInvitationEmail = async ({
|
||||
? `<p style="margin-top: 12px;"><strong>Nachricht:</strong><br>${String(message).replace(/</g, '<').replace(/>/g, '>')}</p>`
|
||||
: '';
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: recipientList.join(','),
|
||||
subject: `Freundschaftsspiel-Einladung: ${fromClubName} -> ${toClubName}`,
|
||||
html: `
|
||||
@@ -90,9 +110,7 @@ const sendFriendlyMatchInvitationEmail = async ({
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value ?? '')
|
||||
@@ -110,8 +128,7 @@ const sendMobileFeedbackEmail = async ({
|
||||
backendBaseUrl,
|
||||
user,
|
||||
}) => {
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: 'tsschulz2001@gmail.com',
|
||||
subject: `Android Feedback${screen ? ` - ${screen}` : ''}`,
|
||||
html: `
|
||||
@@ -129,9 +146,23 @@ const sendMobileFeedbackEmail = async ({
|
||||
<div style="white-space:pre-wrap;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;">${escapeHtml(message)}</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail };
|
||||
const sendClubCommunicationEmail = async ({
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
replyTo,
|
||||
}) => {
|
||||
return sendMail({
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
replyTo: replyTo || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail, sendClubCommunicationEmail };
|
||||
|
||||
@@ -292,7 +292,8 @@ class MemberService {
|
||||
}
|
||||
|
||||
async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate, phone, email, active = true, testMembership = false,
|
||||
picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false, adultReleaseApproved = false, adultReserveApproved = false, contacts = []) {
|
||||
picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false, adultReleaseApproved = false, adultReserveApproved = false,
|
||||
contributionGroupCode = null, contacts = []) {
|
||||
try {
|
||||
await checkAccess(userToken, clubId);
|
||||
let member = null;
|
||||
@@ -300,6 +301,7 @@ class MemberService {
|
||||
member = await Member.findOne({ where: { id: memberId } });
|
||||
}
|
||||
const MemberContact = (await import('../models/MemberContact.js')).default;
|
||||
const normalizedContributionGroupCode = String(contributionGroupCode || '').trim() || null;
|
||||
if (member) {
|
||||
member.firstName = firstName;
|
||||
member.lastName = lastName;
|
||||
@@ -318,6 +320,7 @@ class MemberService {
|
||||
member.memberFormHandedOver = !!memberFormHandedOver;
|
||||
member.adultReleaseApproved = !!adultReleaseApproved;
|
||||
member.adultReserveApproved = !!adultReserveApproved;
|
||||
member.contributionGroupCode = normalizedContributionGroupCode;
|
||||
await member.save();
|
||||
|
||||
// Update contacts if provided
|
||||
@@ -363,6 +366,7 @@ class MemberService {
|
||||
memberFormHandedOver: !!memberFormHandedOver,
|
||||
adultReleaseApproved: !!adultReleaseApproved,
|
||||
adultReserveApproved: !!adultReserveApproved,
|
||||
contributionGroupCode: normalizedContributionGroupCode,
|
||||
});
|
||||
|
||||
// Create contacts if provided
|
||||
|
||||
@@ -7,65 +7,120 @@ const ROLE_PERMISSIONS = {
|
||||
admin: {
|
||||
diary: { read: true, write: true, delete: true },
|
||||
members: { read: true, write: true, delete: true },
|
||||
requests: { read: true, write: true, delete: true },
|
||||
tasks: { read: true, write: true, delete: true },
|
||||
teams: { read: true, write: true, delete: true },
|
||||
schedule: { read: true, write: true, delete: true },
|
||||
tournaments: { read: true, write: true, delete: true },
|
||||
statistics: { read: true, write: true },
|
||||
finance_accounts: { read: true, write: true, delete: true },
|
||||
finance_invoices: { read: true, write: true, delete: true },
|
||||
history: { read: true, write: true },
|
||||
archive: { read: true, write: true },
|
||||
settings: { read: true, write: true },
|
||||
permissions: { read: true, write: true },
|
||||
approvals: { read: true, write: true },
|
||||
communication: { read: true, write: true, delete: true },
|
||||
mytischtennis_admin: { read: true, write: true },
|
||||
predefined_activities: { read: true, write: true, delete: true },
|
||||
},
|
||||
trainer: {
|
||||
diary: { read: true, write: true, delete: true },
|
||||
members: { read: true, write: true, delete: false },
|
||||
requests: { read: true, write: true, delete: false },
|
||||
tasks: { read: true, write: true, delete: false },
|
||||
teams: { read: true, write: true, delete: false },
|
||||
schedule: { read: true, write: false, delete: false },
|
||||
tournaments: { read: true, write: true, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: true, delete: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: true, write: true, delete: true },
|
||||
},
|
||||
team_manager: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: true, write: false, delete: false },
|
||||
requests: { read: true, write: false, delete: false },
|
||||
tasks: { read: true, write: true, delete: false },
|
||||
teams: { read: true, write: true, delete: false },
|
||||
schedule: { read: true, write: true, delete: false },
|
||||
tournaments: { read: true, write: false, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: false, delete: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
tournament_manager: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: true, write: false, delete: false },
|
||||
requests: { read: true, write: false, delete: false },
|
||||
tasks: { read: true, write: false, delete: false },
|
||||
teams: { read: false, write: false, delete: false },
|
||||
schedule: { read: false, write: false, delete: false },
|
||||
tournaments: { read: true, write: true, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: false, delete: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
cashier: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: true, write: false, delete: false },
|
||||
requests: { read: true, write: false, delete: false },
|
||||
tasks: { read: true, write: true, delete: false },
|
||||
teams: { read: false, write: false, delete: false },
|
||||
schedule: { read: false, write: false, delete: false },
|
||||
tournaments: { read: false, write: false, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: true, write: true, delete: false },
|
||||
finance_invoices: { read: true, write: true, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: true, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: false, delete: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
member: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: false, write: false, delete: false },
|
||||
requests: { read: false, write: false, delete: false },
|
||||
tasks: { read: false, write: false, delete: false },
|
||||
teams: { read: false, write: false, delete: false },
|
||||
schedule: { read: false, write: false, delete: false },
|
||||
tournaments: { read: false, write: false, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: false, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: false, write: false, delete: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
@@ -76,6 +131,7 @@ const DEFAULT_ROLE_TEMPLATES = [
|
||||
{ roleKey: 'trainer', name: 'Trainer', description: 'Kann Trainingseinheiten, Mitglieder und Teams verwalten', permissions: ROLE_PERMISSIONS.trainer, sortOrder: 20 },
|
||||
{ roleKey: 'team_manager', name: 'Mannschaftsführer', description: 'Kann Teams und Spielpläne verwalten', permissions: ROLE_PERMISSIONS.team_manager, sortOrder: 30 },
|
||||
{ roleKey: 'tournament_manager', name: 'Turnierleiter', description: 'Kann Turniere verwalten', permissions: ROLE_PERMISSIONS.tournament_manager, sortOrder: 40 },
|
||||
{ roleKey: 'cashier', name: 'Kassierer', description: 'Kann Konten, Rechnungen und finanznahe Aufgaben verwalten', permissions: ROLE_PERMISSIONS.cashier, sortOrder: 45 },
|
||||
{ roleKey: 'member', name: 'Mitglied', description: 'Kann nur freigegebene Vereinsbereiche ansehen', permissions: ROLE_PERMISSIONS.member, sortOrder: 50 },
|
||||
];
|
||||
|
||||
@@ -123,13 +179,20 @@ class PermissionService {
|
||||
return {
|
||||
diary: { label: 'Trainingstagebuch', actions: ['read', 'write', 'delete'] },
|
||||
members: { label: 'Mitglieder', actions: ['read', 'write', 'delete'] },
|
||||
requests: { label: 'Anfragen', actions: ['read', 'write', 'delete'] },
|
||||
tasks: { label: 'Aufgaben', actions: ['read', 'write', 'delete'] },
|
||||
teams: { label: 'Teams', actions: ['read', 'write', 'delete'] },
|
||||
schedule: { label: 'Spielpläne', actions: ['read', 'write', 'delete'] },
|
||||
tournaments: { label: 'Turniere', actions: ['read', 'write', 'delete'] },
|
||||
statistics: { label: 'Statistiken', actions: ['read', 'write'] },
|
||||
finance_accounts: { label: 'Konten', actions: ['read', 'write', 'delete'] },
|
||||
finance_invoices: { label: 'Rechnungen', actions: ['read', 'write', 'delete'] },
|
||||
history: { label: 'Historie', actions: ['read', 'write'] },
|
||||
archive: { label: 'Archiv', actions: ['read', 'write'] },
|
||||
settings: { label: 'Einstellungen', actions: ['read', 'write'] },
|
||||
permissions: { label: 'Berechtigungsverwaltung', actions: ['read', 'write'] },
|
||||
approvals: { label: 'Freigaben (Mitgliedsanträge)', actions: ['read', 'write'] },
|
||||
communication: { label: 'Kommunikation', actions: ['read', 'write', 'delete'] },
|
||||
mytischtennis_admin: { label: 'MyTischtennis Admin', actions: ['read', 'write'] },
|
||||
predefined_activities: { label: 'Vordefinierte Aktivitäten', actions: ['read', 'write', 'delete'] },
|
||||
};
|
||||
@@ -152,7 +215,7 @@ class PermissionService {
|
||||
async ensureDefaultRoles(clubId) {
|
||||
const createdRoles = [];
|
||||
for (const template of DEFAULT_ROLE_TEMPLATES) {
|
||||
const [role] = await ClubRole.findOrCreate({
|
||||
const [role, created] = await ClubRole.findOrCreate({
|
||||
where: { clubId, roleKey: template.roleKey },
|
||||
defaults: {
|
||||
clubId,
|
||||
@@ -164,6 +227,16 @@ class PermissionService {
|
||||
sortOrder: template.sortOrder,
|
||||
},
|
||||
});
|
||||
|
||||
if (!created && role.isSystemRole) {
|
||||
const mergedPermissions = this.mergePermissions(template.permissions, role.permissions);
|
||||
await role.update({
|
||||
name: role.name || template.name,
|
||||
description: role.description || template.description,
|
||||
permissions: mergedPermissions,
|
||||
sortOrder: role.sortOrder || template.sortOrder,
|
||||
});
|
||||
}
|
||||
createdRoles.push(role);
|
||||
}
|
||||
return createdRoles;
|
||||
|
||||
Reference in New Issue
Block a user