5 Commits

Author SHA1 Message Date
Torsten Schulz (local)
ac97332e6f feat: Enhance club payment claims and task automation
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m0s
- Added `paid_amount_cents` column to `club_payment_claims` for better tracking of payments.
- Implemented compatibility checks for the new column in `clubPaymentClaimService` and `clubTaskAutomationService`.
- Updated various views to handle read-only states when editing is not allowed.
- Refactored forms in `ClubAccountsView`, `ClubInvoicesView`, `ClubTasksView`, and `ClubCommunicationView` to use factory functions for cleaner code.
- Introduced a new migration script to add the `paid_amount_cents` column if it doesn't exist and initialize it for existing paid claims.
- Created a detailed plan for enhancing club features and ensuring stability in existing modules.
2026-07-13 17:01:13 +02:00
Torsten Schulz (local)
90e6c2f9f6 Updates, overview extended, club view implemented 2026-06-23 16:17:07 +02:00
Torsten Schulz (local)
dd91c07694 Mitgliedermeldung jetzt wieder möglich 2026-06-21 17:23:29 +02:00
Torsten Schulz (local)
302ed20a9b more club funtions, fix for team edit 2026-06-21 17:15:04 +02:00
Torsten Schulz (local)
542fae089c implementierung der ersten schritte eine komplett-suite 2026-06-19 15:47:32 +02:00
125 changed files with 25468 additions and 758 deletions

View File

@@ -19,7 +19,7 @@ export const createClubCalendarEvent = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId } = req.params;
const event = await calendarEventService.createClubEvent(userToken, clubId, req.body);
const event = await calendarEventService.createClubEvent(userToken, clubId, req.body, req.user?.id || null);
res.status(201).json(event);
} catch (error) {
console.error('[createClubCalendarEvent] - Error:', error);
@@ -28,6 +28,19 @@ export const createClubCalendarEvent = async (req, res) => {
}
};
export const updateClubCalendarEvent = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, eventId } = req.params;
const event = await calendarEventService.updateClubEvent(userToken, clubId, eventId, req.body, req.user?.id || null);
res.status(200).json(event);
} catch (error) {
console.error('[updateClubCalendarEvent] - Error:', error);
const msg = getSafeErrorMessage(error, 'Fehler beim Speichern des Kalender-Events');
res.status(error.statusCode || 500).json({ error: msg });
}
};
export const deleteClubCalendarEvent = async (req, res) => {
try {
const { authcode: userToken } = req.headers;

View File

@@ -0,0 +1,93 @@
import clubAccountService from '../services/clubAccountService.js';
class ClubAccountController {
async listClubAccounts(req, res) {
try {
const { clubId } = req.params;
const payload = await clubAccountService.listClubAccounts(Number(clubId));
res.json(payload);
} catch (error) {
console.error('[listClubAccounts] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konten konnten nicht geladen werden.' });
}
}
async createClubAccount(req, res) {
try {
const { clubId } = req.params;
const account = await clubAccountService.createClubAccount(Number(clubId), req.body || {});
res.status(201).json({ account });
} catch (error) {
console.error('[createClubAccount] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gespeichert werden.' });
}
}
async updateClubAccount(req, res) {
try {
const { clubId, accountId } = req.params;
const account = await clubAccountService.updateClubAccount(Number(clubId), Number(accountId), req.body || {});
res.json({ account });
} catch (error) {
console.error('[updateClubAccount] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gespeichert werden.' });
}
}
async updateClubAccountStatus(req, res) {
try {
const { clubId, accountId } = req.params;
const account = await clubAccountService.updateClubAccountStatus(Number(clubId), Number(accountId), String(req.body?.status || ''));
res.json({ account });
} catch (error) {
console.error('[updateClubAccountStatus] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Kontostatus konnte nicht gespeichert werden.' });
}
}
async deleteClubAccount(req, res) {
try {
const { clubId, accountId } = req.params;
await clubAccountService.deleteClubAccount(Number(clubId), Number(accountId));
res.json({ success: true });
} catch (error) {
console.error('[deleteClubAccount] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gelöscht werden.' });
}
}
async createTransaction(req, res) {
try {
const { clubId } = req.params;
const transaction = await clubAccountService.createTransaction(Number(clubId), req.user?.id || null, req.body || {});
res.status(201).json({ transaction });
} catch (error) {
console.error('[createClubAccountTransaction] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gespeichert werden.' });
}
}
async updateTransaction(req, res) {
try {
const { clubId, transactionId } = req.params;
const transaction = await clubAccountService.updateTransaction(Number(clubId), Number(transactionId), req.body || {});
res.json({ transaction });
} catch (error) {
console.error('[updateClubAccountTransaction] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gespeichert werden.' });
}
}
async deleteTransaction(req, res) {
try {
const { clubId, transactionId } = req.params;
await clubAccountService.deleteTransaction(Number(clubId), Number(transactionId));
res.json({ success: true });
} catch (error) {
console.error('[deleteClubAccountTransaction] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gelöscht werden.' });
}
}
}
export default new ClubAccountController();

View File

@@ -0,0 +1,19 @@
import clubArchiveService from '../services/clubArchiveService.js';
class ClubArchiveController {
async getClubArchive(req, res) {
try {
const { clubId } = req.params;
const archive = await clubArchiveService.getClubArchive(clubId);
res.json(archive);
} catch (error) {
console.error('[getClubArchive] - Error:', error);
if (error?.status) {
return res.status(error.status).json({ error: error.message });
}
res.status(500).json({ error: 'Fehler beim Laden des Vereinsarchivs' });
}
}
}
export default new ClubArchiveController();

View File

@@ -0,0 +1,132 @@
import clubCommunicationService from '../services/clubCommunicationService.js';
function handleError(res, scope, error) {
console.error(`[${scope}] - Error:`, error);
res.status(error?.status || 500).json({
error: error?.message || 'internalerror',
});
}
const clubCommunicationController = {
async list(req, res) {
try {
const { clubId } = req.params;
const payload = await clubCommunicationService.listClubCommunication(Number(clubId));
res.json(payload);
} catch (error) {
handleError(res, 'listClubCommunication', error);
}
},
async createThread(req, res) {
try {
const { clubId } = req.params;
const thread = await clubCommunicationService.createThread(Number(clubId), req.user?.id || null, req.body || {});
res.status(201).json({ thread });
} catch (error) {
handleError(res, 'createClubCommunicationThread', error);
}
},
async updateThread(req, res) {
try {
const { clubId, threadId } = req.params;
const thread = await clubCommunicationService.updateThread(Number(clubId), Number(threadId), req.body || {});
res.json({ thread });
} catch (error) {
handleError(res, 'updateClubCommunicationThread', error);
}
},
async deleteThread(req, res) {
try {
const { clubId, threadId } = req.params;
await clubCommunicationService.deleteThread(Number(clubId), Number(threadId));
res.status(204).send();
} catch (error) {
handleError(res, 'deleteClubCommunicationThread', error);
}
},
async addMessage(req, res) {
try {
const { clubId, threadId } = req.params;
const message = await clubCommunicationService.addMessage(Number(clubId), Number(threadId), req.user?.id || null, req.body || {});
res.status(201).json({ message });
} catch (error) {
handleError(res, 'addClubCommunicationMessage', error);
}
},
async sendThread(req, res) {
try {
const { clubId, threadId } = req.params;
const thread = await clubCommunicationService.sendThread(Number(clubId), Number(threadId), req.user?.id || null);
res.json({ thread });
} catch (error) {
handleError(res, 'sendClubCommunicationThread', error);
}
},
async createGroup(req, res) {
try {
const { clubId } = req.params;
const group = await clubCommunicationService.createGroup(Number(clubId), req.body || {});
res.status(201).json({ group });
} catch (error) {
handleError(res, 'createClubDistributionGroup', error);
}
},
async updateGroup(req, res) {
try {
const { clubId, groupId } = req.params;
const group = await clubCommunicationService.updateGroup(Number(clubId), Number(groupId), req.body || {});
res.json({ group });
} catch (error) {
handleError(res, 'updateClubDistributionGroup', error);
}
},
async deleteGroup(req, res) {
try {
const { clubId, groupId } = req.params;
await clubCommunicationService.deleteGroup(Number(clubId), Number(groupId));
res.status(204).send();
} catch (error) {
handleError(res, 'deleteClubDistributionGroup', error);
}
},
async createTemplate(req, res) {
try {
const { clubId } = req.params;
const template = await clubCommunicationService.createTemplate(Number(clubId), req.body || {});
res.status(201).json({ template });
} catch (error) {
handleError(res, 'createClubCommunicationTemplate', error);
}
},
async updateTemplate(req, res) {
try {
const { clubId, templateId } = req.params;
const template = await clubCommunicationService.updateTemplate(Number(clubId), Number(templateId), req.body || {});
res.json({ template });
} catch (error) {
handleError(res, 'updateClubCommunicationTemplate', error);
}
},
async deleteTemplate(req, res) {
try {
const { clubId, templateId } = req.params;
await clubCommunicationService.deleteTemplate(Number(clubId), Number(templateId));
res.status(204).send();
} catch (error) {
handleError(res, 'deleteClubCommunicationTemplate', error);
}
},
};
export default clubCommunicationController;

View File

@@ -0,0 +1,671 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import {
CalendarEvent,
ClubCommunicationThread,
ClubInvoice,
ClubPaymentClaim,
ClubRequest,
ClubSepaMandate,
ClubTask,
Match,
Member,
TrainingGroup,
} from '../models/index.js';
import clubArchiveService from '../services/clubArchiveService.js';
import { hasClubPaymentClaimPaidAmountCentsColumn } from '../services/clubPaymentClaimCompatibility.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
function formatRequestWorkflowStage(stage) {
return {
contact_replied: 'Kontakt beantwortet',
trial_training_scheduled: 'Probetraining terminiert',
trial_training_feedback_recorded: 'Probetraining nachbereitet',
membership_reviewed: 'Mitgliedsanfrage geprüft',
admission_prepared: 'Aufnahme vorbereitet',
member_record_created: 'Mitglied angelegt',
sepa_pending: 'SEPA ausstehend',
onboarding_completed: 'Onboarding abgeschlossen',
sponsoring_contacted: 'Sponsoring kontaktiert',
sponsoring_offer_prepared: 'Sponsoringangebot vorbereitet',
sponsoring_followed_up: 'Sponsoring nachgefasst',
}[stage] || stage;
}
function formatTaskType(taskType) {
return {
request_contact_reply: 'Kontaktanfrage beantworten',
request_schedule_trial_training: 'Probetraining organisieren',
request_trial_training_follow_up: 'Probetraining nachbereiten',
request_membership_review: 'Mitgliedsanfrage prüfen',
membership_prepare_admission: 'Aufnahme vorbereiten',
membership_create_member_record: 'Mitglied anlegen',
membership_collect_sepa_mandate: 'SEPA organisieren',
membership_assign_fee: 'Beitrag zuordnen',
request_sponsoring_reply: 'Sponsoring nachfassen',
sponsoring_prepare_offer: 'Sponsoringangebot vorbereiten',
sponsoring_follow_up: 'Sponsoring nachfassen',
document_review_required: 'Dokument prüfen',
sponsor_contract_renewal: 'Sponsoring verlängern',
member_missing_email: 'E-Mail ergänzen',
member_missing_birthdate: 'Geburtsdatum ergänzen',
member_missing_sepa_mandate: 'SEPA-Mandat einholen',
payment_claim_due_soon: 'Fällige Zahlung vorbereiten',
payment_claim_overdue: 'Überfällige Zahlung nachfassen',
payment_claim_reminder: 'Mahnstufe prüfen',
calendar_event_prepare: 'Termin vorbereiten',
calendar_event_deadline_check: 'Terminfrist prüfen',
}[taskType] || taskType || 'Freie Aufgabe';
}
function formatCommunicationStatus(status) {
return {
draft: 'Entwurf',
scheduled: 'Geplant',
sent: 'Versendet',
archived: 'Archiviert',
}[status] || status || 'Status';
}
function formatInvoiceStatus(status) {
return {
draft: 'Entwurf',
issued: 'Gestellt',
partially_paid: 'Teilbezahlt',
paid: 'Bezahlt',
cancelled: 'Storniert',
archived: 'Archiviert',
}[status] || status || 'Status';
}
function formatEventDateRange(event) {
if (!event?.startDate) {
return null;
}
const formatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' });
const start = formatter.format(new Date(event.startDate));
const end = event.endDate ? formatter.format(new Date(event.endDate)) : start;
return start === end ? start : `${start} bis ${end}`;
}
function formatDate(value, options = { dateStyle: 'medium' }) {
if (!value) {
return null;
}
return new Intl.DateTimeFormat('de-DE', options).format(new Date(value));
}
function formatTime(value) {
if (!value) {
return null;
}
return String(value).slice(0, 5);
}
function formatWeekday(weekday) {
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][Number(weekday)] || 'Unbekannt';
}
function formatConfiguredTrainingLabel(entry) {
const start = formatTime(entry.startTime);
const end = formatTime(entry.endTime);
const timeRange = start && end ? `${start} bis ${end} Uhr` : start ? `${start} Uhr` : null;
return [entry.groupName, formatWeekday(entry.weekday), timeRange].filter(Boolean).join(' · ');
}
function formatMatchLabel(match) {
const date = formatDate(match.date);
const time = formatTime(match.time);
const homeTeam = match.homeTeam?.name || 'Heimteam';
const guestTeam = match.guestTeam?.name || 'Gastteam';
const league = match.leagueDetails?.name || null;
return [
`${homeTeam} gegen ${guestTeam}`,
date,
time ? `${time} Uhr` : null,
league,
].filter(Boolean).join(' · ');
}
function createDashboardItem(label, to, extra = {}) {
return { label, to, ...extra };
}
function buildMemberRoute(memberId, scope = 'active', extraQuery = {}) {
return {
path: '/members',
query: {
scope,
memberId: String(memberId),
mode: 'edit',
...extraQuery,
},
};
}
function buildRequestRoute(requestId, status = '') {
const query = { requestId: String(requestId) };
if (status) {
query.status = status;
}
return {
path: '/club-requests',
query,
};
}
function buildTaskRoute(taskId, status = '') {
const query = { taskId: String(taskId) };
if (status) {
query.status = status;
}
return {
path: '/club-tasks',
query,
};
}
async function loadAvailableTables() {
const tables = await sequelize.getQueryInterface().showAllTables();
return new Set(
tables
.map((table) => (typeof table === 'string' ? table : Object.values(table || {})[0]))
.filter(Boolean)
.map((table) => String(table).toLowerCase())
);
}
async function loadOptionalTableData(availableTables, tableName, loader, fallbackValue = []) {
if (!availableTables.has(String(tableName).toLowerCase())) {
return fallbackValue;
}
return loader();
}
function countMissingMemberFields(members) {
const missing = {
email: 0,
birthDate: 0,
};
for (const member of members) {
if (!String(member.email || '').trim()) {
missing.email += 1;
}
if (!String(member.birthDate || '').trim()) {
missing.birthDate += 1;
}
}
return missing;
}
function toNextOccurrenceDate(weekday, startTime) {
const now = new Date();
const result = new Date(now);
const targetWeekday = Number(weekday);
const daysUntilWeekday = (targetWeekday - result.getDay() + 7) % 7;
result.setDate(result.getDate() + daysUntilWeekday);
const [hours = '0', minutes = '0'] = String(startTime || '00:00').split(':');
result.setHours(Number(hours), Number(minutes), 0, 0);
if (result < now) {
result.setDate(result.getDate() + 7);
}
return result;
}
function buildUpcomingTrainingSlots(groups, limit = 5) {
return groups
.flatMap((group) => (Array.isArray(group.trainingTimes) ? group.trainingTimes.map((time) => ({
id: time.id,
weekday: time.weekday,
startTime: time.startTime,
endTime: time.endTime,
sortOrder: time.sortOrder,
groupName: group.name,
nextOccurrence: toNextOccurrenceDate(time.weekday, time.startTime),
})) : []))
.sort((left, right) => {
const timeDiff = left.nextOccurrence.getTime() - right.nextOccurrence.getTime();
if (timeDiff !== 0) return timeDiff;
return String(left.groupName || '').localeCompare(String(right.groupName || ''));
})
.slice(0, limit);
}
export const getClubDashboard = async (req, res) => {
try {
const clubId = Number(req.params.clubId);
const currentUserId = Number(req.user?.id) || null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayIso = today.toISOString().slice(0, 10);
const availableTables = await loadAvailableTables();
const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn();
const [
requests,
tasks,
members,
mandates,
paymentClaims,
upcomingEvents,
trainingGroups,
upcomingMatches,
communicationThreads,
invoices,
archive,
] = await Promise.all([
loadOptionalTableData(availableTables, 'club_requests', () => ClubRequest.findAll({
where: {
clubId,
status: { [Op.notIn]: ['archived'] },
},
order: [['receivedAt', 'DESC']],
})),
loadOptionalTableData(availableTables, 'club_tasks', () => ClubTask.findAll({
where: {
clubId,
status: { [Op.notIn]: ['archived'] },
},
order: [['dueAt', 'ASC'], ['updatedAt', 'DESC']],
})),
Member.findAll({
where: {
clubId,
active: true,
},
order: [['createdAt', 'DESC']],
}),
loadOptionalTableData(availableTables, 'club_sepa_mandates', () => ClubSepaMandate.findAll({
where: {
clubId,
status: 'active',
revokedAt: null,
memberId: { [Op.ne]: null },
},
attributes: ['memberId'],
})),
hasPaidAmountCentsColumn
? loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
}))
: Promise.resolve([]),
loadOptionalTableData(availableTables, 'calendar_events', () => CalendarEvent.findAll({
where: {
clubId,
endDate: { [Op.gte]: todayIso },
},
order: [['startDate', 'ASC']],
limit: 5,
})),
loadOptionalTableData(availableTables, 'training_group', () => TrainingGroup.findAll({
where: {
clubId,
},
include: [
{
association: 'trainingTimes',
required: false,
},
],
order: [['isPreset', 'DESC'], ['sortOrder', 'ASC'], ['name', 'ASC']],
})),
loadOptionalTableData(availableTables, 'match', () => Match.findAll({
where: {
clubId,
date: { [Op.gte]: today },
},
include: [
{ association: 'homeTeam', attributes: ['id', 'name'] },
{ association: 'guestTeam', attributes: ['id', 'name'] },
{ association: 'leagueDetails', attributes: ['id', 'name'] },
],
order: [['date', 'ASC'], ['time', 'ASC']],
limit: 5,
})),
loadOptionalTableData(availableTables, 'club_communication_threads', () => ClubCommunicationThread.findAll({
where: { clubId },
attributes: ['id', 'subject', 'status', 'threadType', 'scheduledAt', 'sentAt', 'updatedAt'],
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
limit: 8,
})),
loadOptionalTableData(availableTables, 'club_invoices', () => ClubInvoice.findAll({
where: {
clubId,
archivedAt: null,
},
attributes: ['id', 'invoiceNumber', 'invoiceDirection', 'invoiceType', 'status', 'dueOn', 'grossAmountCents', 'currencyCode', 'updatedAt'],
order: [['updatedAt', 'DESC'], ['dueOn', 'ASC']],
limit: 8,
})),
clubArchiveService.getClubArchive(clubId),
]);
const visibleDashboardTasks = tasks.filter((task) => !task.assignedUserId || Number(task.assignedUserId) === currentUserId);
const membersById = new Map(members.map((member) => [Number(member.id), member]));
const paymentClaimsById = new Map(paymentClaims.map((claim) => [Number(claim.id), claim]));
const missingFields = countMissingMemberFields(members);
const openTasks = visibleDashboardTasks.filter((task) => task.status === 'open');
const inProgressTasks = visibleDashboardTasks.filter((task) => task.status === 'in_progress');
const automatedTasks = visibleDashboardTasks.filter((task) => Boolean(task.automationKey));
const automatedOpenTasks = automatedTasks.filter((task) => ['open', 'in_progress', 'waiting'].includes(task.status));
const overdueTaskCount = visibleDashboardTasks.filter((task) => {
if (!task.dueAt || ['done', 'cancelled', 'archived'].includes(task.status)) {
return false;
}
return new Date(task.dueAt) < today;
}).length;
const memberIdsWithMandate = new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
const missingMandateCount = members.filter((member) => !memberIdsWithMandate.has(Number(member.id))).length;
const openRequestCount = requests.filter((request) => request.status === 'open').length;
const inProgressRequestCount = requests.filter((request) => request.status === 'in_progress').length;
const trialTrainingCount = requests.filter((request) => request.requestType === 'trial_training' && request.status !== 'archived').length;
const workflowStageCounts = requests.reduce((accumulator, request) => {
if (!request.workflowStage) return accumulator;
accumulator[request.workflowStage] = (accumulator[request.workflowStage] || 0) + 1;
return accumulator;
}, {});
const onboardingCount =
(workflowStageCounts.membership_reviewed || 0) +
(workflowStageCounts.admission_prepared || 0) +
(workflowStageCounts.member_record_created || 0) +
(workflowStageCounts.sepa_pending || 0);
const duePaymentCount = paymentClaims.filter((claim) => claim.status === 'open').length;
const reminderCount = paymentClaims.filter((claim) => Number(claim.reminderLevel || 0) > 0).length;
const openInvoices = invoices.filter((invoice) => ['draft', 'issued', 'partially_paid'].includes(invoice.status));
const outgoingOpenInvoices = openInvoices.filter((invoice) => invoice.invoiceDirection === 'outgoing');
const incomingOpenInvoices = openInvoices.filter((invoice) => invoice.invoiceDirection === 'incoming');
const communicationDraftCount = communicationThreads.filter((thread) => thread.status === 'draft').length;
const communicationScheduledCount = communicationThreads.filter((thread) => thread.status === 'scheduled').length;
const communicationSentCount = communicationThreads.filter((thread) => thread.status === 'sent').length;
const recentMembers = members.slice(0, 4);
const upcomingTrainings = buildUpcomingTrainingSlots(trainingGroups);
const paidRatio = paymentClaims.length === 0
? null
: Math.max(
0,
Math.round(
((paymentClaims.length - duePaymentCount) / paymentClaims.length) * 100
)
);
function taskDetailTarget(task) {
if (task.relatedEntityType === 'member' && task.relatedEntityId) {
return buildMemberRoute(task.relatedEntityId, 'active');
}
if (task.relatedEntityType === 'club_request' && task.relatedEntityId) {
const request = requests.find((entry) => Number(entry.id) === Number(task.relatedEntityId));
return buildRequestRoute(task.relatedEntityId, request?.status || '');
}
if (task.relatedEntityType === 'club_payment_claim' && task.relatedEntityId) {
const claim = paymentClaimsById.get(Number(task.relatedEntityId));
if (claim?.memberId) {
return buildMemberRoute(claim.memberId, 'active');
}
}
return buildTaskRoute(task.id, task.status || '');
}
function taskDashboardItem(task, label) {
return createDashboardItem(label, taskDetailTarget(task), {
isAssignedToCurrentUser: Boolean(task.assignedUserId) && Number(task.assignedUserId) === currentUserId,
});
}
const sections = [
{
id: 'action-needed',
title: 'Handlungsbedarf',
cards: [
{
title: 'Neue Anfragen',
value: `${openRequestCount + inProgressRequestCount}`,
meta: trialTrainingCount > 0 ? `${trialTrainingCount} Probetrainings` : null,
to: '/club-requests',
accent: 'amber',
items: [
createDashboardItem(`${openRequestCount} offen`, '/club-requests'),
createDashboardItem(`${inProgressRequestCount} in Bearbeitung`, '/club-requests'),
],
},
{
title: 'Anfrage-Workflows',
value: `${onboardingCount}`,
meta: onboardingCount > 0 ? 'im Aufnahme- und Onboardingprozess' : 'Keine aktiven Onboarding-Fälle',
to: '/club-requests',
accent: 'green',
items: [
createDashboardItem(`${workflowStageCounts.trial_training_scheduled || 0} Probetrainings terminiert`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.membership_reviewed || 0} Mitgliedsanfragen geprüft`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.sepa_pending || 0} Fälle mit ausstehendem SEPA`, '/club-requests'),
],
},
{
title: 'Offene Zahlungen',
value: `${paymentClaims.length}`,
meta: [
duePaymentCount > 0 ? `${duePaymentCount} fällig offen` : null,
reminderCount > 0 ? `${reminderCount} mit Mahnstufe` : null,
].filter(Boolean).join(' · ') || 'Keine Mahnungen aktiv',
to: '/club-payments',
accent: 'red',
items: paymentClaims.slice(0, 3).map((claim) => {
const amount = `${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'}`;
return createDashboardItem(
`${amount} · ${claim.dueOn ? `fällig ${claim.dueOn}` : 'ohne Fälligkeit'}`,
claim.memberId ? buildMemberRoute(claim.memberId, 'active') : '/club-payments'
);
}),
},
{
title: 'Fehlende Daten',
value: `${missingFields.email + missingFields.birthDate + missingMandateCount}`,
to: '/members',
accent: 'blue',
items: [
createDashboardItem(`${missingFields.email} Mitglieder ohne E-Mail`, { path: '/members', query: { scope: 'dataIncomplete' } }),
createDashboardItem(`${missingFields.birthDate} Mitglieder ohne Geburtsdatum`, { path: '/members', query: { scope: 'dataIncomplete' } }),
createDashboardItem(`${missingMandateCount} Mitglieder ohne SEPA-Mandat`, { path: '/members', query: { scope: 'dataIncomplete' } }),
],
},
{
title: 'Offene Aufgaben',
value: `${openTasks.length + inProgressTasks.length}`,
meta: overdueTaskCount > 0 ? `${overdueTaskCount} überfällig` : 'Keine überfälligen Aufgaben',
to: '/club-tasks',
accent: overdueTaskCount > 0 ? 'red' : 'green',
items: [
createDashboardItem(`${automatedOpenTasks.length} automatisch erzeugte Schritte`, '/club-tasks'),
...visibleDashboardTasks.slice(0, 3).map((task) => taskDashboardItem(task, task.title)),
],
},
],
},
{
id: 'operations-room',
title: 'Kommunikation, Finanzen und Archiv',
cards: [
{
title: 'Kommunikation',
value: `${communicationThreads.length}`,
meta: `${communicationDraftCount} Entwürfe, ${communicationScheduledCount} geplant, ${communicationSentCount} versendet`,
to: '/club-communication',
accent: 'blue',
items: communicationThreads.slice(0, 3).map((thread) => createDashboardItem(
`${thread.subject || 'Ohne Betreff'} · ${formatCommunicationStatus(thread.status)}`,
'/club-communication'
)),
},
{
title: 'Rechnungen',
value: `${openInvoices.length}`,
meta: `${outgoingOpenInvoices.length} ausgehend, ${incomingOpenInvoices.length} eingehend`,
to: '/club-invoices',
accent: 'amber',
items: openInvoices.slice(0, 3).map((invoice) => createDashboardItem(
`${invoice.invoiceNumber || `Rechnung #${invoice.id}`} · ${formatInvoiceStatus(invoice.status)}`,
'/club-invoices'
)),
},
{
title: 'Archiv',
value: `${archive.summary.archivedRequests + archive.summary.archivedTasks + archive.summary.archivedClaims}`,
meta: `${archive.summary.inactiveMembers} inaktive Mitglieder`,
to: '/club-archive',
accent: 'neutral',
items: [
createDashboardItem(`${archive.summary.archivedRequests} archivierte Anfragen`, '/club-archive'),
createDashboardItem(`${archive.summary.archivedTasks} archivierte Aufgaben`, '/club-archive'),
createDashboardItem(`${archive.summary.archivedClaims} archivierte Beitragsfälle`, '/club-archive'),
],
},
],
},
{
id: 'appointments',
title: 'Aktuelle Termine',
cards: [
{
title: 'Nächste Trainings',
value: `${upcomingTrainings.length}`,
to: {
path: '/club-settings',
query: { tab: 'training-times' },
},
items: upcomingTrainings.map((training) => createDashboardItem(
formatConfiguredTrainingLabel(training),
{
path: '/club-settings',
query: { tab: 'training-times' },
}
)),
},
{
title: 'Nächste Spiele',
value: `${upcomingMatches.length}`,
to: '/schedule',
items: upcomingMatches.map((match) => createDashboardItem(formatMatchLabel(match), '/schedule')),
},
{
title: 'Kalendertermine',
value: `${upcomingEvents.length}`,
to: '/calendar',
items: upcomingEvents.map((event) => createDashboardItem(`${event.title} · ${formatEventDateRange(event)}`, '/calendar')),
},
],
},
{
id: 'club-status',
title: 'Vereinsstatus',
cards: [
{
title: 'Mitglieder',
value: `${members.length} aktiv`,
meta: members.length > 0 ? `${members.filter((member) => {
const createdAt = new Date(member.createdAt);
return createdAt.getFullYear() === today.getFullYear();
}).length} dieses Jahr angelegt` : null,
to: '/members',
items: recentMembers.map((member) => {
const name = [member.firstName, member.lastName].filter(Boolean).join(' ').trim() || member.email || `Mitglied ${member.id}`;
return createDashboardItem(name, buildMemberRoute(member.id, 'active'));
}),
},
{
title: 'Anfragen',
value: `${requests.length}`,
meta: `${openRequestCount} offen, ${inProgressRequestCount} in Bearbeitung`,
to: '/club-requests',
},
{
title: 'Workflow-Fortschritt',
value: `${workflowStageCounts.onboarding_completed || 0}`,
meta: 'Onboardings abgeschlossen',
to: '/club-requests',
items: [
createDashboardItem(`${workflowStageCounts.admission_prepared || 0} Aufnahmen vorbereitet`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.member_record_created || 0} Mitglieder angelegt`, '/club-requests'),
createDashboardItem(`${workflowStageCounts.sepa_pending || 0} warten auf SEPA`, '/club-requests'),
],
},
{
title: 'Finanzen',
value: paidRatio === null ? 'Keine Daten' : `${paidRatio} % erledigt`,
meta: paymentClaims.length > 0 ? `${paymentClaims.length} offene oder teilweise offene Forderungen` : 'Noch keine Beitragsforderungen erfasst',
to: '/club-tasks',
},
],
},
{
id: 'recent-activity',
title: 'Letzte Aktivitäten',
cards: [
{
title: 'Zuletzt eingegangen',
to: '/club-requests',
items: requests.slice(0, 4).map((request) => {
const name = [request.firstName, request.lastName].filter(Boolean).join(' ').trim() || request.email || 'Unbekannt';
const workflow = request.workflowStage ? ` · ${formatRequestWorkflowStage(request.workflowStage)}` : '';
return createDashboardItem(
`${name} · ${request.subject || request.requestType}${workflow}`,
buildRequestRoute(request.id, request.status || '')
);
}),
},
{
title: 'Aktuelle Aufgaben',
to: '/club-tasks',
items: visibleDashboardTasks.slice(0, 4).map((task) => taskDashboardItem(task, `${formatTaskType(task.taskType)} · ${task.status}`)),
},
{
title: 'Automatik zuletzt aktiv',
to: '/club-tasks',
items: automatedTasks.slice(0, 4).map((task) => {
const sourceLabel = task.automationSource === 'club_requests'
? 'Anfrage'
: task.automationSource === 'club_payment_claims'
? 'Zahlung'
: task.automationSource === 'club_documents'
? 'Dokument'
: task.automationSource === 'club_invoice_parties'
? 'Sponsor'
: task.automationSource === 'club_invoices'
? 'Rechnung'
: task.automationSource === 'club_communication'
? 'Kommunikation'
: task.automationSource === 'calendar_events'
? 'Termin'
: 'Workflow';
return taskDashboardItem(task, `${formatTaskType(task.taskType)} · ${sourceLabel}`);
}),
},
],
},
];
res.status(200).json({ sections });
} catch (error) {
console.error('[getClubDashboard] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Dashboard konnte nicht geladen werden.'),
});
}
};

View File

@@ -0,0 +1,111 @@
import multer from 'multer';
import fs from 'fs';
import path from 'path';
import clubDocumentService from '../services/clubDocumentService.js';
const storage = multer.diskStorage({
destination: (req, file, cb) => {
fs.mkdirSync('uploads/temp', { recursive: true });
cb(null, 'uploads/temp/');
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
cb(null, `${file.fieldname}-${uniqueSuffix}${path.extname(file.originalname)}`);
},
});
const upload = multer({
storage,
limits: {
fileSize: 20 * 1024 * 1024,
},
fileFilter: (req, file, cb) => {
const allowedExtensions = ['.pdf', '.doc', '.docx', '.txt', '.csv', '.odt', '.xlsx'];
const allowedMimePatterns = ['pdf', 'msword', 'wordprocessingml.document', 'text/plain', 'csv', 'excel', 'opendocument.text'];
const extensionValid = allowedExtensions.includes(path.extname(file.originalname).toLowerCase());
const mimetypeValid = allowedMimePatterns.some((pattern) => file.mimetype && file.mimetype.toLowerCase().includes(pattern));
if (extensionValid && mimetypeValid) {
return cb(null, true);
}
cb(new Error('Nur PDF, DOC, DOCX, TXT, CSV, ODT und XLSX Dateien sind erlaubt!'));
},
});
export const uploadMiddleware = upload.single('document');
function cleanupTempFile(file) {
if (file?.path && fs.existsSync(file.path)) {
fs.unlinkSync(file.path);
}
}
export const listClubDocuments = async (req, res) => {
try {
const documents = await clubDocumentService.listClubDocuments(Number(req.params.clubId), req.query || {});
res.status(200).json({ documents });
} catch (error) {
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export const createClubDocument = async (req, res) => {
try {
const document = await clubDocumentService.createClubDocument(Number(req.params.clubId), req.user?.id || null, req.body || {}, req.file || null);
res.status(201).json({ document });
} catch (error) {
cleanupTempFile(req.file);
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export const updateClubDocument = async (req, res) => {
try {
const document = await clubDocumentService.updateClubDocument(Number(req.params.clubId), Number(req.params.documentId), req.user?.id || null, req.body || {}, req.file || null);
res.status(200).json({ document });
} catch (error) {
cleanupTempFile(req.file);
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export const archiveClubDocument = async (req, res) => {
try {
const document = await clubDocumentService.archiveClubDocument(Number(req.params.clubId), Number(req.params.documentId));
res.status(200).json({ document });
} catch (error) {
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export const deleteClubDocument = async (req, res) => {
try {
await clubDocumentService.deleteClubDocument(Number(req.params.clubId), Number(req.params.documentId));
res.status(204).end();
} catch (error) {
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export const downloadClubDocument = async (req, res) => {
try {
const versionNo = req.query.versionNo ? Number(req.query.versionNo) : null;
const { document, version } = await clubDocumentService.getDocumentDownload(Number(req.params.clubId), Number(req.params.documentId), versionNo);
if (!fs.existsSync(version.storagePath)) {
return res.status(404).json({ error: 'filenotfound' });
}
res.setHeader('Content-Disposition', `inline; filename="${document.title || version.fileName}"`);
res.setHeader('Content-Type', version.mimeType || 'application/octet-stream');
res.sendFile(version.storagePath);
} catch (error) {
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
}
};
export default {
listClubDocuments,
createClubDocument,
updateClubDocument,
archiveClubDocument,
deleteClubDocument,
downloadClubDocument,
};

View File

@@ -0,0 +1,93 @@
import clubInvoiceService from '../services/clubInvoiceService.js';
class ClubInvoiceController {
async listClubInvoices(req, res) {
try {
const { clubId } = req.params;
const payload = await clubInvoiceService.listClubInvoices(Number(clubId));
res.json(payload);
} catch (error) {
console.error('[listClubInvoices] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungen konnten nicht geladen werden.' });
}
}
async createInvoiceParty(req, res) {
try {
const { clubId } = req.params;
const party = await clubInvoiceService.createInvoiceParty(Number(clubId), req.body || {});
res.status(201).json({ party });
} catch (error) {
console.error('[createInvoiceParty] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gespeichert werden.' });
}
}
async updateInvoiceParty(req, res) {
try {
const { clubId, partyId } = req.params;
const party = await clubInvoiceService.updateInvoiceParty(Number(clubId), Number(partyId), req.body || {});
res.json({ party });
} catch (error) {
console.error('[updateInvoiceParty] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gespeichert werden.' });
}
}
async deleteInvoiceParty(req, res) {
try {
const { clubId, partyId } = req.params;
await clubInvoiceService.deleteInvoiceParty(Number(clubId), Number(partyId));
res.json({ success: true });
} catch (error) {
console.error('[deleteInvoiceParty] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gelöscht werden.' });
}
}
async createInvoice(req, res) {
try {
const { clubId } = req.params;
const invoice = await clubInvoiceService.createInvoice(Number(clubId), req.user?.id || null, req.body || {});
res.status(201).json({ invoice });
} catch (error) {
console.error('[createInvoice] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gespeichert werden.' });
}
}
async updateInvoice(req, res) {
try {
const { clubId, invoiceId } = req.params;
const invoice = await clubInvoiceService.updateInvoice(Number(clubId), Number(invoiceId), req.body || {});
res.json({ invoice });
} catch (error) {
console.error('[updateInvoice] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gespeichert werden.' });
}
}
async updateInvoiceStatus(req, res) {
try {
const { clubId, invoiceId } = req.params;
const invoice = await clubInvoiceService.updateInvoiceStatus(Number(clubId), Number(invoiceId), String(req.body?.status || ''));
res.json({ invoice });
} catch (error) {
console.error('[updateInvoiceStatus] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungsstatus konnte nicht gespeichert werden.' });
}
}
async deleteInvoice(req, res) {
try {
const { clubId, invoiceId } = req.params;
await clubInvoiceService.deleteInvoice(Number(clubId), Number(invoiceId));
res.json({ success: true });
} catch (error) {
console.error('[deleteInvoice] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gelöscht werden.' });
}
}
}
export default new ClubInvoiceController();

View File

@@ -0,0 +1,73 @@
import clubPaymentClaimService from '../services/clubPaymentClaimService.js';
class ClubPaymentClaimController {
async list(req, res) {
try {
const payload = await clubPaymentClaimService.listClaims(Number(req.params.clubId));
res.json(payload);
} catch (error) {
console.error('[listClubPaymentClaims] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderungen konnten nicht geladen werden.' });
}
}
async create(req, res) {
try {
const claim = await clubPaymentClaimService.createClaim(Number(req.params.clubId), req.body || {});
res.status(201).json({ claim });
} catch (error) {
console.error('[createClubPaymentClaim] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gespeichert werden.' });
}
}
async update(req, res) {
try {
const claim = await clubPaymentClaimService.updateClaim(Number(req.params.clubId), Number(req.params.claimId), req.body || {});
res.json({ claim });
} catch (error) {
console.error('[updateClubPaymentClaim] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gespeichert werden.' });
}
}
async updateStatus(req, res) {
try {
const claim = await clubPaymentClaimService.updateClaimStatus(
Number(req.params.clubId),
Number(req.params.claimId),
String(req.body?.status || '')
);
res.json({ claim });
} catch (error) {
console.error('[updateClubPaymentClaimStatus] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Status konnte nicht gespeichert werden.' });
}
}
async registerPayment(req, res) {
try {
const claim = await clubPaymentClaimService.registerPayment(
Number(req.params.clubId),
Number(req.params.claimId),
req.body || {}
);
res.json({ claim });
} catch (error) {
console.error('[registerClubPaymentClaimPayment] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Teilzahlung konnte nicht gespeichert werden.' });
}
}
async delete(req, res) {
try {
await clubPaymentClaimService.deleteClaim(Number(req.params.clubId), Number(req.params.claimId));
res.json({ success: true });
} catch (error) {
console.error('[deleteClubPaymentClaim] - Error:', error);
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gelöscht werden.' });
}
}
}
export default new ClubPaymentClaimController();

View File

@@ -0,0 +1,174 @@
import { ClubRequest, ClubRequestNote } from '../models/index.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
const TERMINAL_REQUEST_STATUSES = new Set(['converted', 'rejected', 'archived']);
function isMissingRequestTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_requests|club_request_notes/.test(String(error?.original?.sqlMessage || ''));
}
function normalizeRequestPayload(payload = {}) {
return {
requestType: payload.requestType || 'contact',
subject: payload.subject?.trim() || null,
firstName: payload.firstName?.trim() || null,
lastName: payload.lastName?.trim() || null,
email: payload.email?.trim() || null,
phone: payload.phone?.trim() || null,
message: payload.message?.trim() || null,
};
}
async function loadRequestOrThrow(clubId, requestId) {
const request = await ClubRequest.findOne({
where: {
id: requestId,
clubId,
},
include: [
{
model: ClubRequestNote,
as: 'notes',
required: false,
},
],
order: [[{ model: ClubRequestNote, as: 'notes' }, 'createdAt', 'DESC']],
});
if (!request) {
const error = new Error('Anfrage wurde nicht gefunden.');
error.statusCode = 404;
throw error;
}
return request;
}
export const listClubRequests = async (req, res) => {
try {
const { clubId } = req.params;
let requests = [];
try {
requests = await ClubRequest.findAll({
where: { clubId },
include: [
{
model: ClubRequestNote,
as: 'notes',
required: false,
},
],
order: [
['receivedAt', 'DESC'],
[{ model: ClubRequestNote, as: 'notes' }, 'createdAt', 'DESC'],
],
});
} catch (error) {
if (!isMissingRequestTableError(error)) {
throw error;
}
}
res.status(200).json({ requests });
} catch (error) {
console.error('[listClubRequests] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Anfragen konnten nicht geladen werden.'),
});
}
};
export const createClubRequest = async (req, res) => {
try {
const { clubId } = req.params;
const payload = normalizeRequestPayload(req.body);
if (!payload.subject && !payload.message) {
return res.status(400).json({ error: 'Betreff oder Nachricht sind erforderlich.' });
}
const request = await ClubRequest.create({
clubId,
...payload,
receivedAt: new Date(),
});
const created = await loadRequestOrThrow(clubId, request.id);
res.status(201).json({ request: created });
} catch (error) {
console.error('[createClubRequest] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Anfrage konnte nicht gespeichert werden.'),
});
}
};
export const updateClubRequest = async (req, res) => {
try {
const { clubId, requestId } = req.params;
const request = await loadRequestOrThrow(clubId, requestId);
const payload = normalizeRequestPayload(req.body);
await request.update(payload);
const updated = await loadRequestOrThrow(clubId, requestId);
res.status(200).json({ request: updated });
} catch (error) {
console.error('[updateClubRequest] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Anfrage konnte nicht gespeichert werden.'),
});
}
};
export const updateClubRequestStatus = async (req, res) => {
try {
const { clubId, requestId } = req.params;
const { status } = req.body || {};
if (!status) {
return res.status(400).json({ error: 'Status fehlt.' });
}
const request = await loadRequestOrThrow(clubId, requestId);
await request.update({
status,
closedAt: TERMINAL_REQUEST_STATUSES.has(status) ? new Date() : null,
});
const updated = await loadRequestOrThrow(clubId, requestId);
res.status(200).json({ request: updated });
} catch (error) {
console.error('[updateClubRequestStatus] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Status konnte nicht gespeichert werden.'),
});
}
};
export const addClubRequestNote = async (req, res) => {
try {
const { clubId, requestId } = req.params;
const body = String(req.body?.body || '').trim();
if (!body) {
return res.status(400).json({ error: 'Notiztext fehlt.' });
}
await loadRequestOrThrow(clubId, requestId);
await ClubRequestNote.create({
clubRequestId: requestId,
createdByUserId: req.user?.id || null,
body,
});
const updated = await loadRequestOrThrow(clubId, requestId);
res.status(201).json({ request: updated });
} catch (error) {
console.error('[addClubRequestNote] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Notiz konnte nicht gespeichert werden.'),
});
}
};

View File

@@ -0,0 +1,19 @@
import clubStatisticsService from '../services/clubStatisticsService.js';
class ClubStatisticsController {
async getClubStatistics(req, res) {
try {
const { clubId } = req.params;
const statistics = await clubStatisticsService.getClubStatistics(clubId);
res.json(statistics);
} catch (error) {
console.error('[getClubStatistics] - Error:', error);
if (error?.status) {
return res.status(error.status).json({ error: error.message });
}
res.status(500).json({ error: 'Fehler beim Laden der Vereinsstatistiken' });
}
}
}
export default new ClubStatisticsController();

View File

@@ -0,0 +1,275 @@
import { ClubTask, User, UserClub } from '../models/index.js';
import { getSafeErrorMessage } from '../utils/errorUtils.js';
import clubTaskAutomationService from '../services/clubTaskAutomationService.js';
import clubWorkflowSourceService from '../services/clubWorkflowSourceService.js';
const TERMINAL_TASK_STATUSES = new Set(['done', 'cancelled', 'archived']);
function isMissingTaskTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_tasks/.test(String(error?.original?.sqlMessage || ''));
}
function isMissingTaskSuppressionTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_task_suppressions/.test(String(error?.original?.sqlMessage || ''));
}
function normalizeTaskPayload(payload = {}) {
return {
title: String(payload.title || '').trim(),
taskType: payload.taskType?.trim() || null,
description: payload.description?.trim() || null,
status: payload.status || 'open',
priority: payload.priority || 'normal',
dueAt: payload.dueAt || null,
remindAt: payload.remindAt || null,
assignedUserId: payload.assignedUserId ? Number(payload.assignedUserId) : null,
automationSource: payload.automationSource?.trim() || null,
automationKey: payload.automationKey?.trim() || null,
relatedEntityType: payload.relatedEntityType?.trim() || null,
relatedEntityId: payload.relatedEntityId ? Number(payload.relatedEntityId) : null,
sourceSnapshot: payload.sourceSnapshot || null,
};
}
async function loadAssignableUsers(clubId) {
const entries = await UserClub.findAll({
where: { clubId },
include: [{ model: User, as: 'user', attributes: ['id', 'email'] }],
order: [[{ model: User, as: 'user' }, 'email', 'ASC']],
});
return entries
.filter((entry) => entry.user)
.filter((entry) => entry.approved || entry.isOwner)
.map((entry) => ({
userId: entry.userId,
email: entry.user.email,
isOwner: Boolean(entry.isOwner),
approved: Boolean(entry.approved),
}));
}
async function validateAssignedUser(clubId, assignedUserId) {
if (!assignedUserId) {
return null;
}
const userClub = await UserClub.findOne({
where: {
clubId,
userId: assignedUserId,
},
});
if (!userClub || (!userClub.approved && !userClub.isOwner)) {
const error = new Error('Der zugewiesene Benutzer gehört nicht zu diesem Verein.');
error.statusCode = 400;
throw error;
}
return assignedUserId;
}
async function loadTaskOrThrow(clubId, taskId) {
const task = await ClubTask.findOne({
where: {
id: taskId,
clubId,
},
});
if (!task) {
const error = new Error('Aufgabe wurde nicht gefunden.');
error.statusCode = 404;
throw error;
}
return task;
}
export const listClubTasks = async (req, res) => {
try {
const { clubId } = req.params;
let tasks = [];
let automationOverview = { definitions: [], suggestions: [] };
try {
[tasks, automationOverview] = await Promise.all([
ClubTask.findAll({
where: { clubId },
include: [{ model: User, as: 'assignedUser', attributes: ['id', 'email'], required: false }],
order: [
['status', 'ASC'],
['dueAt', 'ASC'],
['updatedAt', 'DESC'],
],
}),
clubTaskAutomationService.buildAutomationOverview(clubId),
]);
} catch (error) {
if (!isMissingTaskTableError(error)) {
throw error;
}
}
const assignableUsers = await loadAssignableUsers(clubId);
res.status(200).json({
tasks,
taskDefinitions: automationOverview.definitions,
workflowSources: automationOverview.workflowSources || [],
taskSuggestions: automationOverview.suggestions,
assignableUsers,
});
} catch (error) {
console.error('[listClubTasks] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgaben konnten nicht geladen werden.'),
});
}
};
export const createClubTask = async (req, res) => {
try {
const { clubId } = req.params;
const payload = normalizeTaskPayload(req.body);
if (!payload.title) {
return res.status(400).json({ error: 'Titel ist erforderlich.' });
}
payload.assignedUserId = await validateAssignedUser(clubId, payload.assignedUserId);
const task = await ClubTask.create({
clubId,
...payload,
createdByUserId: req.user?.id || null,
});
res.status(201).json({ task });
} catch (error) {
console.error('[createClubTask] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gespeichert werden.'),
});
}
};
export const updateClubTask = async (req, res) => {
try {
const { clubId, taskId } = req.params;
const task = await loadTaskOrThrow(clubId, taskId);
const payload = normalizeTaskPayload(req.body);
const wasDoneBefore = task.status === 'done';
if (!payload.title) {
return res.status(400).json({ error: 'Titel ist erforderlich.' });
}
payload.assignedUserId = await validateAssignedUser(clubId, payload.assignedUserId);
await task.update({
...payload,
completedAt: TERMINAL_TASK_STATUSES.has(payload.status) ? (task.completedAt || new Date()) : null,
archivedAt: payload.status === 'archived' ? (task.archivedAt || new Date()) : null,
});
const followUpTasks = !wasDoneBefore && payload.status === 'done'
? await clubTaskAutomationService.materializeWorkflowFollowUps(task, req.user?.id || null)
: [];
const sourceUpdate = !wasDoneBefore && payload.status === 'done'
? await clubWorkflowSourceService.syncSourceStateForCompletedTask(task)
: null;
res.status(200).json({ task, followUpTasks, sourceUpdate });
} catch (error) {
console.error('[updateClubTask] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gespeichert werden.'),
});
}
};
export const updateClubTaskStatus = async (req, res) => {
try {
const { clubId, taskId } = req.params;
const { status } = req.body || {};
if (!status) {
return res.status(400).json({ error: 'Status fehlt.' });
}
const task = await loadTaskOrThrow(clubId, taskId);
const wasDoneBefore = task.status === 'done';
await task.update({
status,
completedAt: TERMINAL_TASK_STATUSES.has(status) ? (task.completedAt || new Date()) : null,
archivedAt: status === 'archived' ? (task.archivedAt || new Date()) : null,
});
const followUpTasks = !wasDoneBefore && status === 'done'
? await clubTaskAutomationService.materializeWorkflowFollowUps(task, req.user?.id || null)
: [];
const sourceUpdate = !wasDoneBefore && status === 'done'
? await clubWorkflowSourceService.syncSourceStateForCompletedTask(task)
: null;
res.status(200).json({ task, followUpTasks, sourceUpdate });
} catch (error) {
console.error('[updateClubTaskStatus] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Status konnte nicht gespeichert werden.'),
});
}
};
export const materializeAutomatedClubTasks = async (req, res) => {
try {
const { clubId } = req.params;
const automationKeys = Array.isArray(req.body?.automationKeys) ? req.body.automationKeys : [];
if (automationKeys.length === 0) {
return res.status(400).json({ error: 'Es wurden keine Automatik-Schlüssel übergeben.' });
}
const tasks = await clubTaskAutomationService.materializeSuggestions(clubId, req.user?.id || null, automationKeys);
res.status(201).json({ tasks });
} catch (error) {
console.error('[materializeAutomatedClubTasks] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Automatische Aufgaben konnten nicht erstellt werden.'),
});
}
};
export const dismissAutomatedClubTaskSuggestion = async (req, res) => {
try {
const { clubId } = req.params;
const payload = req.body || {};
const suppression = await clubTaskAutomationService.dismissSuggestion(clubId, req.user?.id || null, payload);
res.status(200).json({ success: true, suppression });
} catch (error) {
console.error('[dismissAutomatedClubTaskSuggestion] - Error:', error);
if (isMissingTaskSuppressionTableError(error)) {
return res.status(500).json({
error: 'Die Tabelle club_task_suppressions fehlt noch. Bitte die aktuelle SQL-Datei auf dem System ausführen.',
});
}
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Vorschlag konnte nicht ausgeblendet werden.'),
});
}
};
export const deleteClubTask = async (req, res) => {
try {
const { clubId, taskId } = req.params;
const task = await loadTaskOrThrow(clubId, taskId);
await task.destroy();
res.status(200).json({ success: true });
} catch (error) {
console.error('[deleteClubTask] - Error:', error);
res.status(error.statusCode || 500).json({
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gelöscht werden.'),
});
}
};

View File

@@ -67,7 +67,12 @@ export const updateClubSettings = async (req, res) => {
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
memberDataQualityRequirements,
feeRules,
outgoingInvoicePrefix,
outgoingInvoiceNextNumber,
incomingInvoicePrefix,
incomingInvoiceNextNumber
} = req.body;
const updated = await ClubService.updateClubSettings(token, clubid, {
greetingText,
@@ -76,7 +81,12 @@ export const updateClubSettings = async (req, res) => {
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
memberDataQualityRequirements,
feeRules,
outgoingInvoicePrefix,
outgoingInvoiceNextNumber,
incomingInvoicePrefix,
incomingInvoiceNextNumber
});
res.status(200).json(updated);
} catch (error) {

View File

@@ -28,12 +28,13 @@ const getWaitingApprovals = async(req, res) => {
const setClubMembers = async (req, res) => {
try {
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts } = req.body;
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved,
contributionGroupCode, contacts } = req.body;
const { id: clubId } = req.params;
const { authcode: userToken } = req.headers;
const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate,
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts);
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contributionGroupCode, contacts);
// Emit Socket-Event wenn Member erfolgreich erstellt/aktualisiert wurde
if (addResult.status === 200) {
@@ -47,6 +48,33 @@ const setClubMembers = async (req, res) => {
}
}
const getMemberSepaMandate = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.getMemberSepaMandate(userToken, Number(clubId), Number(memberId));
res.status(result.status || 500).json(result.response);
} catch (error) {
console.error('[getMemberSepaMandate] - Error:', error);
res.status(500).json({ success: false, error: 'SEPA-Mandat konnte nicht geladen werden.' });
}
};
const saveMemberSepaMandate = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await MemberService.saveMemberSepaMandate(userToken, Number(clubId), Number(memberId), req.body || {});
if (result.status === 200) {
emitMemberChanged(clubId);
}
res.status(result.status || 500).json(result.response);
} catch (error) {
console.error('[saveMemberSepaMandate] - Error:', error);
res.status(500).json({ success: false, error: 'SEPA-Mandat konnte nicht gespeichert werden.' });
}
};
const getMemberPlayInterests = async (req, res) => {
try {
const { clubId } = req.params;
@@ -327,6 +355,8 @@ export {
getClubMembers,
getWaitingApprovals,
setClubMembers,
getMemberSepaMandate,
saveMemberSepaMandate,
getMemberPlayInterests,
setMemberPlayInterest,
uploadMemberImage,

View File

@@ -10,6 +10,7 @@ import Season from '../models/Season.js';
import User from '../models/User.js';
import HttpError from '../exceptions/HttpError.js';
import { devLog } from '../utils/logger.js';
import { hasUserClubAccess } from '../utils/userUtils.js';
import { randomUUID } from 'crypto';
const teamDataFetchJobs = new Map();
@@ -635,15 +636,34 @@ class MyTischtennisUrlController {
/**
* Configure league from myTischtennis table URL
* POST /api/mytischtennis/configure-league
* Body: { url: string, createSeason?: boolean }
* Body: { url: string, clubId: number, createSeason?: boolean }
*/
async configureLeague(req, res, next) {
try {
const { url, createSeason } = req.body;
const { url, createSeason, clubId } = req.body;
const userIdOrEmail = req.headers.userid;
if (!url) {
throw new HttpError('URL is required', 400);
if (!url || !clubId) {
throw new HttpError('URL and clubId are required', 400);
}
let userId = userIdOrEmail;
if (isNaN(userIdOrEmail)) {
const user = await User.findOne({ where: { email: userIdOrEmail } });
if (!user) {
throw new HttpError('User not found', 404);
}
userId = user.id;
}
const normalizedClubId = Number.parseInt(clubId, 10);
if (!Number.isInteger(normalizedClubId) || normalizedClubId <= 0) {
throw new HttpError('clubId must be a valid number', 400);
}
const hasAccess = await hasUserClubAccess(userId, normalizedClubId);
if (!hasAccess) {
throw new HttpError('Keine Berechtigung für diesen Verein', 403);
}
// Parse URL
@@ -669,6 +689,7 @@ class MyTischtennisUrlController {
// Find or create league
let league = await League.findOne({
where: {
clubId: normalizedClubId,
myTischtennisGroupId: parsedData.groupId,
association: parsedData.association
}
@@ -677,6 +698,7 @@ class MyTischtennisUrlController {
if (!league) {
league = await League.create({
name: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version
clubId: normalizedClubId,
myTischtennisGroupId: parsedData.groupId,
association: parsedData.association,
groupname: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version

View File

@@ -76,6 +76,29 @@ export const updateUserRole = async (req, res) => {
}
};
export const updateUserRoles = async (req, res) => {
try {
const { clubId, userId: targetUserId } = req.params;
const { roleIds } = req.body;
const updatingUserId = req.user.id;
const result = await permissionService.setUserRoles(
parseInt(targetUserId),
parseInt(clubId),
Array.isArray(roleIds) ? roleIds : [],
updatingUserId
);
res.json(result);
} catch (error) {
console.error('Error updating user roles:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
/**
* Update user custom permissions
*/
@@ -128,6 +151,62 @@ export const getPermissionStructure = async (req, res) => {
}
};
export const getClubRoles = async (req, res) => {
try {
const { clubId } = req.params;
const roles = await permissionService.getClubRoles(parseInt(clubId, 10), req.user.id);
res.json(roles);
} catch (error) {
console.error('Error getting club roles:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
export const createClubRole = async (req, res) => {
try {
const { clubId } = req.params;
const role = await permissionService.createClubRole(parseInt(clubId, 10), req.body || {}, req.user.id);
res.status(201).json(role);
} catch (error) {
console.error('Error creating club role:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
export const updateClubRole = async (req, res) => {
try {
const { clubId, roleId } = req.params;
const role = await permissionService.updateClubRole(parseInt(clubId, 10), parseInt(roleId, 10), req.body || {}, req.user.id);
res.json(role);
} catch (error) {
console.error('Error updating club role:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
export const deleteClubRole = async (req, res) => {
try {
const { clubId, roleId } = req.params;
const result = await permissionService.deleteClubRole(parseInt(clubId, 10), parseInt(roleId, 10), req.user.id);
res.json(result);
} catch (error) {
console.error('Error deleting club role:', error);
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
return res.status(403).json({ error: error.message });
}
res.status(400).json({ error: error.message });
}
};
/**
* Update user status (activate/deactivate)
*/
@@ -158,10 +237,14 @@ export default {
getUserPermissions,
getClubMembersWithPermissions,
updateUserRole,
updateUserRoles,
updateUserPermissions,
updateUserStatus,
getAvailableRoles,
getPermissionStructure
getPermissionStructure,
getClubRoles,
createClubRole,
updateClubRole,
deleteClubRole,
};

View File

@@ -142,7 +142,7 @@ export const requireAdmin = () => {
parseInt(clubId)
);
if (!userPermissions || (userPermissions.role !== 'admin' && !userPermissions.isOwner)) {
if (!userPermissions || (!userPermissions.isAdmin && !userPermissions.isOwner)) {
return res.status(403).json({
error: 'Keine Berechtigung',
details: 'Administrator-Rechte erforderlich'
@@ -190,7 +190,10 @@ export const requireRole = (roles) => {
parseInt(clubId)
);
if (!userPermissions || !roles.includes(userPermissions.role)) {
const assignedRoleKeys = Array.isArray(userPermissions?.roles)
? userPermissions.roles.map((role) => role.roleKey)
: [];
if (!userPermissions || (!roles.includes(userPermissions.role) && !assignedRoleKeys.some((roleKey) => roles.includes(roleKey)))) {
return res.status(403).json({
error: 'Keine Berechtigung',
details: `Erforderliche Rolle: ${roles.join(', ')}`
@@ -212,4 +215,3 @@ export default {
requireAdmin,
requireRole
};

View File

@@ -0,0 +1,36 @@
-- club_payment_claims: Feld wie backend/models/ClubPaymentClaim.js (paidAmountCents)
-- Fehlt in der DB -> SequelizeDatabaseError ER_BAD_FIELD_ERROR in Club-Dashboard,
-- Aufgaben-Automation, Konten und Zahlungsforderungen.
--
-- Diese Migration ist idempotent:
-- - fuegt `paid_amount_cents` nur hinzu, wenn die Spalte noch fehlt
-- - initialisiert bestehende Datensaetze mit Status `paid` auf `amount_cents`
-- Hinweis:
-- Bereits teilweise bezahlte Altfaelle koennen ohne historische Buchungsdaten
-- nicht exakt rekonstruiert werden und bleiben daher initial bei 0.
SET @column_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'club_payment_claims'
AND COLUMN_NAME = 'paid_amount_cents'
);
SET @add_column_sql := IF(
@column_exists = 0,
'ALTER TABLE `club_payment_claims`
ADD COLUMN `paid_amount_cents` BIGINT NOT NULL DEFAULT 0
COMMENT ''Bereits bezahlter Anteil in Cent''
AFTER `amount_cents`',
'SELECT ''Column paid_amount_cents already exists'' AS message'
);
PREPARE add_column_stmt FROM @add_column_sql;
EXECUTE add_column_stmt;
DEALLOCATE PREPARE add_column_stmt;
UPDATE `club_payment_claims`
SET `paid_amount_cents` = `amount_cents`
WHERE `status` = 'paid'
AND COALESCE(`paid_amount_cents`, 0) = 0;

View File

@@ -1,14 +1,22 @@
CREATE TABLE IF NOT EXISTS calendar_events (
id INT AUTO_INCREMENT PRIMARY KEY,
club_id INT NOT NULL,
event_type VARCHAR(32) NOT NULL DEFAULT 'club_event',
status VARCHAR(32) NOT NULL DEFAULT 'planning',
title VARCHAR(255) NOT NULL,
description TEXT NULL,
location VARCHAR(255) NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
registration_deadline DATE NULL,
organizer_user_id INT NULL,
category VARCHAR(64) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_calendar_events_club_start (club_id, start_date),
KEY idx_calendar_events_club_status (club_id, status),
KEY idx_calendar_events_club_event_type (club_id, event_type),
CONSTRAINT fk_calendar_events_club
FOREIGN KEY (club_id) REFERENCES clubs(id)
ON DELETE CASCADE

View File

@@ -10,16 +10,44 @@ const CalendarEvent = sequelize.define('CalendarEvent', {
references: { model: Club, key: 'id' },
onDelete: 'CASCADE',
},
eventType: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'club_event',
field: 'event_type',
},
status: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'planning',
},
title: { type: DataTypes.STRING(255), allowNull: false },
description: { type: DataTypes.TEXT, allowNull: true },
location: { type: DataTypes.STRING(255), allowNull: true },
startDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'start_date' },
endDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'end_date' },
registrationDeadline: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'registration_deadline',
},
organizerUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'organizer_user_id',
},
category: { type: DataTypes.STRING(64), allowNull: true },
notes: { type: DataTypes.TEXT, allowNull: true },
}, {
tableName: 'calendar_events',
underscored: true,
timestamps: true,
indexes: [{ fields: ['club_id', 'start_date'] }],
indexes: [
{ fields: ['club_id', 'start_date'] },
{ fields: ['club_id', 'event_type'] },
{ fields: ['club_id', 'status'] },
{ fields: ['club_id', 'registration_deadline'] },
],
});
export default CalendarEvent;

View File

@@ -48,6 +48,36 @@ const Club = sequelize.define('Club', {
allowNull: true,
field: 'member_data_quality_requirements',
comment: 'Configures which member fields are required for data quality checks'
},
feeRules: {
type: DataTypes.JSON,
allowNull: true,
field: 'fee_rules',
comment: 'Manual contribution rules for common club member groups'
},
outgoingInvoicePrefix: {
type: DataTypes.STRING(24),
allowNull: false,
defaultValue: 'RE',
field: 'outgoing_invoice_prefix'
},
outgoingInvoiceNextNumber: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
field: 'outgoing_invoice_next_number'
},
incomingInvoicePrefix: {
type: DataTypes.STRING(24),
allowNull: false,
defaultValue: 'EI',
field: 'incoming_invoice_prefix'
},
incomingInvoiceNextNumber: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
field: 'incoming_invoice_next_number'
}
}, {
tableName: 'clubs',

View File

@@ -0,0 +1,94 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubAccount = sequelize.define('ClubAccount', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
name: {
type: DataTypes.STRING(160),
allowNull: false,
},
accountHolder: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'account_holder',
},
bankName: {
type: DataTypes.STRING(160),
allowNull: true,
field: 'bank_name',
},
iban: {
type: DataTypes.STRING(34),
allowNull: true,
},
bic: {
type: DataTypes.STRING(11),
allowNull: true,
},
accountType: {
type: DataTypes.ENUM('bank', 'cash', 'virtual'),
allowNull: false,
defaultValue: 'bank',
field: 'account_type',
},
usageType: {
type: DataTypes.ENUM('general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash'),
allowNull: false,
defaultValue: 'general',
field: 'usage_type',
},
currencyCode: {
type: DataTypes.STRING(3),
allowNull: false,
defaultValue: 'EUR',
field: 'currency_code',
},
allowSepaCollections: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'allow_sepa_collections',
},
allowOutgoingPayments: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
field: 'allow_outgoing_payments',
},
isDefault: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_default',
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'archived'),
allowNull: false,
defaultValue: 'active',
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at',
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'sort_order',
},
}, {
tableName: 'club_accounts',
underscored: true,
timestamps: true,
});
export default ClubAccount;

View File

@@ -0,0 +1,87 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubAccountTransaction = sequelize.define('ClubAccountTransaction', {
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'club_id',
},
accountId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'account_id',
},
invoiceId: {
type: DataTypes.BIGINT,
allowNull: true,
field: 'invoice_id',
},
paymentClaimId: {
type: DataTypes.BIGINT,
allowNull: true,
field: 'payment_claim_id',
},
direction: {
type: DataTypes.ENUM('credit', 'debit'),
allowNull: false,
defaultValue: 'credit',
},
bookingType: {
type: DataTypes.ENUM('manual', 'invoice', 'adjustment', 'payment_claim'),
allowNull: false,
defaultValue: 'manual',
field: 'booking_type',
},
status: {
type: DataTypes.ENUM('planned', 'booked', 'cancelled'),
allowNull: false,
defaultValue: 'booked',
},
bookingDate: {
type: DataTypes.DATEONLY,
allowNull: false,
field: 'booking_date',
},
valueDate: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'value_date',
},
amountCents: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'amount_cents',
},
currencyCode: {
type: DataTypes.STRING(3),
allowNull: false,
defaultValue: 'EUR',
field: 'currency_code',
},
reference: {
type: DataTypes.STRING(255),
allowNull: true,
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
},
createdByUserId: {
type: DataTypes.BIGINT,
allowNull: true,
field: 'created_by_user_id',
},
}, {
underscored: true,
tableName: 'club_account_transactions',
timestamps: true,
});
export default ClubAccountTransaction;

View File

@@ -0,0 +1,73 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubCommunicationDeliveryLog = sequelize.define('ClubCommunicationDeliveryLog', {
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'club_id',
},
threadId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'thread_id',
},
recipientId: {
type: DataTypes.BIGINT,
allowNull: true,
field: 'recipient_id',
},
createdByUserId: {
type: DataTypes.BIGINT,
allowNull: true,
field: 'created_by_user_id',
},
status: {
type: DataTypes.ENUM('sent', 'failed', 'skipped'),
allowNull: false,
defaultValue: 'failed',
},
attemptNo: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
field: 'attempt_no',
},
retryable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
errorCode: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'error_code',
},
errorMessage: {
type: DataTypes.TEXT,
allowNull: true,
field: 'error_message',
},
transportMessageId: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'transport_message_id',
},
transportResponse: {
type: DataTypes.TEXT,
allowNull: true,
field: 'transport_response',
},
}, {
underscored: true,
tableName: 'club_communication_delivery_logs',
timestamps: true,
});
export default ClubCommunicationDeliveryLog;

View File

@@ -0,0 +1,47 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubCommunicationMessage = sequelize.define('ClubCommunicationMessage', {
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
threadId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'thread_id',
},
clubId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'club_id',
},
messageType: {
type: DataTypes.ENUM('message', 'note'),
allowNull: false,
defaultValue: 'message',
field: 'message_type',
},
direction: {
type: DataTypes.ENUM('outbound', 'internal', 'inbound'),
allowNull: false,
defaultValue: 'outbound',
},
body: {
type: DataTypes.TEXT,
allowNull: false,
},
createdByUserId: {
type: DataTypes.BIGINT,
allowNull: true,
field: 'created_by_user_id',
},
}, {
underscored: true,
tableName: 'club_communication_messages',
timestamps: true,
});
export default ClubCommunicationMessage;

View File

@@ -0,0 +1,84 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubCommunicationRecipient = sequelize.define('ClubCommunicationRecipient', {
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
threadId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'thread_id',
},
clubId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'club_id',
},
memberId: {
type: DataTypes.BIGINT,
allowNull: true,
field: 'member_id',
},
recipientName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'recipient_name',
},
emailSnapshot: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'email_snapshot',
},
deliveryStatus: {
type: DataTypes.ENUM('pending', 'sent', 'failed', 'skipped'),
allowNull: false,
defaultValue: 'pending',
field: 'delivery_status',
},
deliveredAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'delivered_at',
},
lastAttemptAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_attempt_at',
},
attemptCount: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'attempt_count',
},
retryable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
errorCode: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'error_code',
},
transportMessageId: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'transport_message_id',
},
errorMessage: {
type: DataTypes.TEXT,
allowNull: true,
field: 'error_message',
},
}, {
underscored: true,
tableName: 'club_communication_recipients',
timestamps: true,
});
export default ClubCommunicationRecipient;

View File

@@ -0,0 +1,58 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubCommunicationTemplate = sequelize.define('ClubCommunicationTemplate', {
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'club_id',
},
name: {
type: DataTypes.STRING(160),
allowNull: false,
},
category: {
type: DataTypes.STRING(64),
allowNull: false,
defaultValue: 'general',
},
subjectTemplate: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'subject_template',
},
bodyTemplate: {
type: DataTypes.TEXT,
allowNull: false,
field: 'body_template',
},
variablesHint: {
type: DataTypes.TEXT,
allowNull: true,
field: 'variables_hint',
},
isSystemTemplate: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_system_template',
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'sort_order',
},
}, {
underscored: true,
tableName: 'club_communication_templates',
timestamps: true,
});
export default ClubCommunicationTemplate;

View File

@@ -0,0 +1,67 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubCommunicationThread = sequelize.define('ClubCommunicationThread', {
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'club_id',
},
threadType: {
type: DataTypes.ENUM('direct', 'group', 'broadcast'),
allowNull: false,
defaultValue: 'direct',
field: 'thread_type',
},
subject: {
type: DataTypes.STRING(255),
allowNull: false,
},
status: {
type: DataTypes.ENUM('draft', 'scheduled', 'sent', 'archived'),
allowNull: false,
defaultValue: 'draft',
},
createdByUserId: {
type: DataTypes.BIGINT,
allowNull: true,
field: 'created_by_user_id',
},
distributionGroupId: {
type: DataTypes.BIGINT,
allowNull: true,
field: 'distribution_group_id',
},
recipientMemberId: {
type: DataTypes.BIGINT,
allowNull: true,
field: 'recipient_member_id',
},
scheduledAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'scheduled_at',
},
recipientFilters: {
type: DataTypes.JSON,
allowNull: true,
field: 'recipient_filters',
},
sentAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'sent_at',
},
}, {
underscored: true,
tableName: 'club_communication_threads',
timestamps: true,
});
export default ClubCommunicationThread;

View File

@@ -0,0 +1,47 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubDistributionGroup = sequelize.define('ClubDistributionGroup', {
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'club_id',
},
name: {
type: DataTypes.STRING(255),
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
groupType: {
type: DataTypes.ENUM('manual', 'training_group', 'team', 'custom'),
allowNull: false,
defaultValue: 'manual',
field: 'group_type',
},
isSystemGroup: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_system_group',
},
filterDefinition: {
type: DataTypes.JSON,
allowNull: true,
field: 'filter_definition',
},
}, {
underscored: true,
tableName: 'club_distribution_groups',
timestamps: true,
});
export default ClubDistributionGroup;

View File

@@ -0,0 +1,33 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubDistributionGroupMember = sequelize.define('ClubDistributionGroupMember', {
id: {
type: DataTypes.BIGINT,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
groupId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'group_id',
},
memberId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'member_id',
},
}, {
underscored: true,
tableName: 'club_distribution_group_members',
timestamps: true,
indexes: [
{
unique: true,
fields: ['group_id', 'member_id'],
},
],
});
export default ClubDistributionGroupMember;

View File

@@ -0,0 +1,61 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubDocument = sequelize.define('ClubDocument', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
documentType: {
type: DataTypes.STRING(32),
allowNull: false,
field: 'document_type',
},
title: {
type: DataTypes.STRING(255),
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
status: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'active',
},
visibilityScope: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'board',
field: 'visibility_scope',
},
ownerUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'owner_user_id',
},
currentVersionNo: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
field: 'current_version_no',
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at',
},
}, {
tableName: 'club_documents',
underscored: true,
timestamps: true,
indexes: [
{ fields: ['club_id', 'document_type', 'status'] },
{ fields: ['club_id', 'visibility_scope'] },
{ fields: ['club_id', 'current_version_no'] },
],
});
export default ClubDocument;

View File

@@ -0,0 +1,35 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubDocumentLink = sequelize.define('ClubDocumentLink', {
documentId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'document_id',
},
linkedEntityType: {
type: DataTypes.STRING(32),
allowNull: false,
field: 'linked_entity_type',
},
linkedEntityId: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'linked_entity_id',
},
createdAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'created_at',
},
}, {
tableName: 'club_document_links',
underscored: true,
timestamps: false,
indexes: [
{ fields: ['linked_entity_type', 'linked_entity_id'] },
],
});
export default ClubDocumentLink;

View File

@@ -0,0 +1,65 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubDocumentVersion = sequelize.define('ClubDocumentVersion', {
documentId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'document_id',
},
versionNo: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'version_no',
},
fileName: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'file_name',
},
storagePath: {
type: DataTypes.STRING(500),
allowNull: false,
field: 'storage_path',
},
mimeType: {
type: DataTypes.STRING(120),
allowNull: true,
field: 'mime_type',
},
fileSizeBytes: {
type: DataTypes.BIGINT,
allowNull: true,
field: 'file_size_bytes',
},
checksumSha256: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'checksum_sha256',
},
uploadedByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'uploaded_by_user_id',
},
uploadedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'uploaded_at',
},
changeNote: {
type: DataTypes.TEXT,
allowNull: true,
field: 'change_note',
},
}, {
tableName: 'club_document_versions',
underscored: true,
timestamps: false,
indexes: [
{ unique: true, fields: ['document_id', 'version_no'] },
],
});
export default ClubDocumentVersion;

View File

@@ -0,0 +1,109 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubInvoice = sequelize.define('ClubInvoice', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
invoiceDirection: {
type: DataTypes.STRING(16),
allowNull: false,
field: 'invoice_direction',
},
invoiceType: {
type: DataTypes.STRING(32),
allowNull: false,
field: 'invoice_type',
},
status: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'draft',
},
invoiceNumber: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'invoice_number',
},
externalReference: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'external_reference',
},
partyId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'party_id',
},
accountId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'account_id',
},
issuedOn: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'issued_on',
},
dueOn: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'due_on',
},
paidOn: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'paid_on',
},
netAmountCents: {
type: DataTypes.BIGINT,
allowNull: false,
defaultValue: 0,
field: 'net_amount_cents',
},
taxAmountCents: {
type: DataTypes.BIGINT,
allowNull: false,
defaultValue: 0,
field: 'tax_amount_cents',
},
grossAmountCents: {
type: DataTypes.BIGINT,
allowNull: false,
defaultValue: 0,
field: 'gross_amount_cents',
},
currencyCode: {
type: DataTypes.STRING(3),
allowNull: false,
defaultValue: 'EUR',
field: 'currency_code',
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
documentId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'document_id',
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_by_user_id',
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at',
},
}, {
tableName: 'club_invoices',
underscored: true,
timestamps: true,
});
export default ClubInvoice;

View File

@@ -0,0 +1,49 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubInvoiceItem = sequelize.define('ClubInvoiceItem', {
invoiceId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'invoice_id',
},
lineNo: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
field: 'line_no',
},
description: {
type: DataTypes.TEXT,
allowNull: false,
},
quantity: {
type: DataTypes.DECIMAL(12, 2),
allowNull: false,
defaultValue: 1,
},
unitPriceCents: {
type: DataTypes.BIGINT,
allowNull: false,
defaultValue: 0,
field: 'unit_price_cents',
},
taxRate: {
type: DataTypes.DECIMAL(5, 2),
allowNull: false,
defaultValue: 0,
field: 'tax_rate',
},
totalCents: {
type: DataTypes.BIGINT,
allowNull: false,
defaultValue: 0,
field: 'total_cents',
},
}, {
tableName: 'club_invoice_items',
underscored: true,
timestamps: true,
});
export default ClubInvoiceItem;

View File

@@ -0,0 +1,101 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubInvoiceParty = sequelize.define('ClubInvoiceParty', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
partyType: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'customer',
field: 'party_type',
},
status: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'active',
},
name: {
type: DataTypes.STRING(255),
allowNull: false,
},
contractReference: {
type: DataTypes.STRING(120),
allowNull: true,
field: 'contract_reference',
},
validFrom: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'valid_from',
},
validTo: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'valid_to',
},
contactName: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'contact_name',
},
email: {
type: DataTypes.STRING(255),
allowNull: true,
},
phone: {
type: DataTypes.STRING(80),
allowNull: true,
},
street: {
type: DataTypes.STRING(255),
allowNull: true,
},
postalCode: {
type: DataTypes.STRING(24),
allowNull: true,
field: 'postal_code',
},
city: {
type: DataTypes.STRING(120),
allowNull: true,
},
countryCode: {
type: DataTypes.STRING(2),
allowNull: true,
defaultValue: 'DE',
field: 'country_code',
},
iban: {
type: DataTypes.STRING(34),
allowNull: true,
},
bic: {
type: DataTypes.STRING(11),
allowNull: true,
},
taxIdentifier: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'tax_identifier',
},
notes: {
type: DataTypes.TEXT,
allowNull: true,
},
}, {
tableName: 'club_invoice_parties',
underscored: true,
timestamps: true,
indexes: [
{ fields: ['club_id', 'party_type'] },
{ fields: ['club_id', 'status'] },
{ fields: ['club_id', 'valid_from'] },
{ fields: ['club_id', 'valid_to'] },
],
});
export default ClubInvoiceParty;

View File

@@ -0,0 +1,89 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubPaymentClaim = sequelize.define('ClubPaymentClaim', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
memberId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'member_id'
},
feeRuleId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'fee_rule_id'
},
claimType: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'membership_fee',
field: 'claim_type'
},
status: {
type: DataTypes.ENUM('open', 'partially_paid', 'paid', 'written_off', 'cancelled'),
allowNull: false,
defaultValue: 'open'
},
dueOn: {
type: DataTypes.DATEONLY,
allowNull: false,
field: 'due_on'
},
amountCents: {
type: DataTypes.BIGINT,
allowNull: false,
field: 'amount_cents'
},
paidAmountCents: {
type: DataTypes.BIGINT,
allowNull: false,
defaultValue: 0,
field: 'paid_amount_cents'
},
currencyCode: {
type: DataTypes.STRING(3),
allowNull: false,
defaultValue: 'EUR',
field: 'currency_code'
},
reminderLevel: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'reminder_level'
},
lastReminderAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_reminder_at'
},
notes: {
type: DataTypes.TEXT,
allowNull: true
},
settledAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'settled_at'
},
lastPaidAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'last_paid_at'
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at'
}
}, {
tableName: 'club_payment_claims',
underscored: true,
timestamps: true
});
export default ClubPaymentClaim;

View File

@@ -0,0 +1,94 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubRequest = sequelize.define('ClubRequest', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
requestType: {
type: DataTypes.ENUM('contact', 'trial_training', 'membership', 'sponsoring'),
allowNull: false,
defaultValue: 'contact',
field: 'request_type'
},
status: {
type: DataTypes.ENUM('open', 'in_progress', 'waiting', 'converted', 'rejected', 'archived'),
allowNull: false,
defaultValue: 'open'
},
workflowStage: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'workflow_stage'
},
priority: {
type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'),
allowNull: false,
defaultValue: 'normal'
},
subject: {
type: DataTypes.STRING,
allowNull: true
},
firstName: {
type: DataTypes.STRING,
allowNull: true,
field: 'first_name'
},
lastName: {
type: DataTypes.STRING,
allowNull: true,
field: 'last_name'
},
email: {
type: DataTypes.STRING,
allowNull: true
},
phone: {
type: DataTypes.STRING,
allowNull: true
},
message: {
type: DataTypes.TEXT,
allowNull: true
},
sourceSystem: {
type: DataTypes.STRING,
allowNull: true,
field: 'source_system'
},
receivedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'received_at'
},
assignedUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'assigned_user_id'
},
assignedMemberId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'assigned_member_id'
},
convertedMemberId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'converted_member_id'
},
closedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'closed_at'
}
}, {
tableName: 'club_requests',
underscored: true,
timestamps: true
});
export default ClubRequest;

View File

@@ -0,0 +1,32 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubRequestNote = sequelize.define('ClubRequestNote', {
clubRequestId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_request_id'
},
noteType: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'internal',
field: 'note_type'
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_by_user_id'
},
body: {
type: DataTypes.TEXT,
allowNull: false
}
}, {
tableName: 'club_request_notes',
underscored: true,
timestamps: true,
updatedAt: false
});
export default ClubRequestNote;

View File

@@ -0,0 +1,58 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubRole = sequelize.define('ClubRole', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
roleKey: {
type: DataTypes.STRING(64),
allowNull: false,
field: 'role_key',
},
name: {
type: DataTypes.STRING(120),
allowNull: false,
},
description: {
type: DataTypes.TEXT,
allowNull: true,
},
permissions: {
type: DataTypes.JSON,
allowNull: false,
defaultValue: {},
},
isSystemRole: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_system_role',
},
sortOrder: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: 'sort_order',
},
}, {
tableName: 'club_roles',
underscored: true,
timestamps: true,
indexes: [
{
unique: true,
fields: ['club_id', 'role_key'],
},
],
});
export default ClubRole;

View File

@@ -0,0 +1,64 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubSepaMandate = sequelize.define('ClubSepaMandate', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
memberId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'member_id'
},
debtorName: {
type: DataTypes.STRING,
allowNull: false,
field: 'debtor_name'
},
iban: {
type: DataTypes.STRING(34),
allowNull: false
},
bic: {
type: DataTypes.STRING(11),
allowNull: true
},
mandateReference: {
type: DataTypes.STRING(80),
allowNull: false,
field: 'mandate_reference'
},
signedOn: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'signed_on'
},
validFrom: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'valid_from'
},
revokedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'revoked_at'
},
status: {
type: DataTypes.STRING(32),
allowNull: false,
defaultValue: 'active'
},
historyNote: {
type: DataTypes.TEXT,
allowNull: true,
field: 'history_note'
}
}, {
tableName: 'club_sepa_mandates',
underscored: true,
timestamps: true
});
export default ClubSepaMandate;

View File

@@ -0,0 +1,94 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubTask = sequelize.define('ClubTask', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
title: {
type: DataTypes.STRING(255),
allowNull: false
},
taskType: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'task_type'
},
description: {
type: DataTypes.TEXT,
allowNull: true
},
status: {
type: DataTypes.ENUM('open', 'in_progress', 'waiting', 'done', 'cancelled', 'archived'),
allowNull: false,
defaultValue: 'open'
},
priority: {
type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'),
allowNull: false,
defaultValue: 'normal'
},
dueAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'due_at'
},
remindAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'remind_at'
},
createdByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'created_by_user_id'
},
assignedUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'assigned_user_id'
},
automationSource: {
type: DataTypes.STRING(64),
allowNull: true,
field: 'automation_source'
},
automationKey: {
type: DataTypes.STRING(255),
allowNull: true,
field: 'automation_key'
},
relatedEntityType: {
type: DataTypes.STRING(32),
allowNull: true,
field: 'related_entity_type'
},
relatedEntityId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'related_entity_id'
},
completedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'completed_at'
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at'
},
sourceSnapshot: {
type: DataTypes.JSON,
allowNull: true,
field: 'source_snapshot'
}
}, {
tableName: 'club_tasks',
underscored: true,
timestamps: true
});
export default ClubTask;

View File

@@ -0,0 +1,31 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubTaskSuppression = sequelize.define('ClubTaskSuppression', {
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
automationKey: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'automation_key'
},
suppressionToken: {
type: DataTypes.STRING(255),
allowNull: false,
field: 'suppression_token'
},
dismissedByUserId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'dismissed_by_user_id'
}
}, {
tableName: 'club_task_suppressions',
underscored: true,
timestamps: true
});
export default ClubTaskSuppression;

View File

@@ -0,0 +1,44 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const ClubUserRole = sequelize.define('ClubUserRole', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id',
},
userId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'user_id',
},
clubRoleId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_role_id',
},
isPrimary: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_primary',
},
}, {
tableName: 'club_user_roles',
underscored: true,
timestamps: true,
indexes: [
{
unique: true,
fields: ['club_id', 'user_id', 'club_role_id'],
},
],
});
export default ClubUserRole;

View File

@@ -123,6 +123,60 @@ const Member = sequelize.define('Member', {
type: DataTypes.INTEGER,
allowNull: false,
},
membershipStatus: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'active',
field: 'membership_status',
},
membershipType: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'regular',
field: 'membership_type',
},
joinedOn: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'joined_on',
},
leftOn: {
type: DataTypes.DATEONLY,
allowNull: true,
field: 'left_on',
},
contributionGroupCode: {
type: DataTypes.STRING,
allowNull: true,
field: 'contribution_group_code',
},
needsSepaMandate: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'needs_sepa_mandate',
},
sepaMandateReference: {
type: DataTypes.STRING,
allowNull: true,
field: 'sepa_mandate_reference',
},
isArchived: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
field: 'is_archived',
},
archivedAt: {
type: DataTypes.DATE,
allowNull: true,
field: 'archived_at',
},
archivedReason: {
type: DataTypes.TEXT,
allowNull: true,
field: 'archived_reason',
},
active: {
type: DataTypes.BOOLEAN,
allowNull: false,

View File

@@ -57,6 +57,9 @@ import BillingRun from './BillingRun.js';
import BillingDocument from './BillingDocument.js';
import BillingDocumentValue from './BillingDocumentValue.js';
import BillingUserSetting from './BillingUserSetting.js';
import ClubDocument from './ClubDocument.js';
import ClubDocumentVersion from './ClubDocumentVersion.js';
import ClubDocumentLink from './ClubDocumentLink.js';
import FriendlyMatch from './FriendlyMatch.js';
import FriendlyMatchShared from './FriendlyMatchShared.js';
import FriendlyMatchInvitation from './FriendlyMatchInvitation.js';
@@ -70,6 +73,26 @@ import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
import TrainingTime from './TrainingTime.js';
import ClubVenue from './ClubVenue.js';
import ClubRequest from './ClubRequest.js';
import ClubRequestNote from './ClubRequestNote.js';
import ClubSepaMandate from './ClubSepaMandate.js';
import ClubPaymentClaim from './ClubPaymentClaim.js';
import ClubAccount from './ClubAccount.js';
import ClubAccountTransaction from './ClubAccountTransaction.js';
import ClubInvoiceParty from './ClubInvoiceParty.js';
import ClubInvoice from './ClubInvoice.js';
import ClubInvoiceItem from './ClubInvoiceItem.js';
import ClubTask from './ClubTask.js';
import ClubTaskSuppression from './ClubTaskSuppression.js';
import ClubRole from './ClubRole.js';
import ClubUserRole from './ClubUserRole.js';
import ClubCommunicationThread from './ClubCommunicationThread.js';
import ClubCommunicationMessage from './ClubCommunicationMessage.js';
import ClubCommunicationRecipient from './ClubCommunicationRecipient.js';
import ClubCommunicationDeliveryLog from './ClubCommunicationDeliveryLog.js';
import ClubCommunicationTemplate from './ClubCommunicationTemplate.js';
import ClubDistributionGroup from './ClubDistributionGroup.js';
import ClubDistributionGroupMember from './ClubDistributionGroupMember.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -172,11 +195,22 @@ ClubTeam.belongsTo(League, { foreignKey: 'leagueId', as: 'league' });
Season.hasMany(ClubTeam, { foreignKey: 'seasonId', as: 'clubTeams' });
ClubTeam.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
ClubTeam.hasMany(ClubTeamMember, { foreignKey: 'clubTeamId', as: 'lineupMembers' });
ClubTeamMember.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
Member.hasMany(ClubTeamMember, { foreignKey: 'memberId', as: 'clubTeamAssignments' });
ClubTeamMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
// TeamDocument relationships
ClubTeam.hasMany(TeamDocument, { foreignKey: 'clubTeamId', as: 'documents' });
TeamDocument.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
Club.hasMany(ClubDocument, { foreignKey: 'clubId', as: 'documents' });
ClubDocument.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubDocument.hasMany(ClubDocumentVersion, { foreignKey: 'documentId', as: 'versions' });
ClubDocumentVersion.belongsTo(ClubDocument, { foreignKey: 'documentId', as: 'document' });
ClubDocument.hasMany(ClubDocumentLink, { foreignKey: 'documentId', as: 'links' });
ClubDocumentLink.belongsTo(ClubDocument, { foreignKey: 'documentId', as: 'document' });
Match.belongsTo(Location, { foreignKey: 'locationId', as: 'location' });
Location.hasMany(Match, { foreignKey: 'locationId', as: 'matches' });
@@ -447,6 +481,114 @@ FriendlyMatchInvitation.hasOne(FriendlyMatchShared, {
Club.hasMany(ClubVenue, { foreignKey: 'clubId', as: 'venues' });
ClubVenue.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(ClubRequest, { foreignKey: 'clubId', as: 'clubRequests' });
ClubRequest.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubRequest.hasMany(ClubRequestNote, { foreignKey: 'clubRequestId', as: 'notes' });
ClubRequestNote.belongsTo(ClubRequest, { foreignKey: 'clubRequestId', as: 'request' });
User.hasMany(ClubRequest, { foreignKey: 'assignedUserId', as: 'assignedClubRequests' });
ClubRequest.belongsTo(User, { foreignKey: 'assignedUserId', as: 'assignedUser', constraints: false });
Member.hasMany(ClubRequest, { foreignKey: 'assignedMemberId', as: 'assignedRequests' });
ClubRequest.belongsTo(Member, { foreignKey: 'assignedMemberId', as: 'assignedMember', constraints: false });
User.hasMany(ClubRequestNote, { foreignKey: 'createdByUserId', as: 'clubRequestNotes' });
ClubRequestNote.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
Club.hasMany(ClubSepaMandate, { foreignKey: 'clubId', as: 'sepaMandates' });
ClubSepaMandate.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Member.hasMany(ClubSepaMandate, { foreignKey: 'memberId', as: 'sepaMandates' });
ClubSepaMandate.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
Club.hasMany(ClubPaymentClaim, { foreignKey: 'clubId', as: 'paymentClaims' });
ClubPaymentClaim.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Member.hasMany(ClubPaymentClaim, { foreignKey: 'memberId', as: 'paymentClaims' });
ClubPaymentClaim.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
ClubPaymentClaim.hasMany(ClubAccountTransaction, { foreignKey: 'paymentClaimId', as: 'transactions' });
ClubAccountTransaction.belongsTo(ClubPaymentClaim, { foreignKey: 'paymentClaimId', as: 'paymentClaim', constraints: false });
Club.hasMany(ClubAccount, { foreignKey: 'clubId', as: 'accounts' });
ClubAccount.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(ClubAccountTransaction, { foreignKey: 'clubId', as: 'accountTransactions' });
ClubAccountTransaction.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubAccount.hasMany(ClubAccountTransaction, { foreignKey: 'accountId', as: 'transactions' });
ClubAccountTransaction.belongsTo(ClubAccount, { foreignKey: 'accountId', as: 'account' });
Club.hasMany(ClubInvoiceParty, { foreignKey: 'clubId', as: 'invoiceParties' });
ClubInvoiceParty.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(ClubInvoice, { foreignKey: 'clubId', as: 'invoices' });
ClubInvoice.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubInvoice.belongsTo(ClubInvoiceParty, { foreignKey: 'partyId', as: 'party', constraints: false });
ClubInvoiceParty.hasMany(ClubInvoice, { foreignKey: 'partyId', as: 'invoices' });
ClubInvoice.belongsTo(ClubAccount, { foreignKey: 'accountId', as: 'account', constraints: false });
ClubAccount.hasMany(ClubInvoice, { foreignKey: 'accountId', as: 'invoices' });
User.hasMany(ClubInvoice, { foreignKey: 'createdByUserId', as: 'createdInvoices' });
ClubInvoice.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
ClubInvoice.hasMany(ClubAccountTransaction, { foreignKey: 'invoiceId', as: 'transactions' });
ClubAccountTransaction.belongsTo(ClubInvoice, { foreignKey: 'invoiceId', as: 'invoice', constraints: false });
User.hasMany(ClubAccountTransaction, { foreignKey: 'createdByUserId', as: 'createdAccountTransactions' });
ClubAccountTransaction.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
ClubInvoice.hasMany(ClubInvoiceItem, { foreignKey: 'invoiceId', as: 'items' });
ClubInvoiceItem.belongsTo(ClubInvoice, { foreignKey: 'invoiceId', as: 'invoice' });
Club.hasMany(ClubTask, { foreignKey: 'clubId', as: 'clubTasks' });
ClubTask.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
User.hasMany(ClubTask, { foreignKey: 'createdByUserId', as: 'createdClubTasks' });
ClubTask.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
User.hasMany(ClubTask, { foreignKey: 'assignedUserId', as: 'assignedClubTasksWork' });
ClubTask.belongsTo(User, { foreignKey: 'assignedUserId', as: 'assignedUser', constraints: false });
Club.hasMany(ClubTaskSuppression, { foreignKey: 'clubId', as: 'taskSuppressions' });
ClubTaskSuppression.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
User.hasMany(ClubTaskSuppression, { foreignKey: 'dismissedByUserId', as: 'dismissedTaskSuggestions' });
ClubTaskSuppression.belongsTo(User, { foreignKey: 'dismissedByUserId', as: 'dismissedByUser', constraints: false });
Club.hasMany(ClubRole, { foreignKey: 'clubId', as: 'clubRoles' });
ClubRole.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubRole.hasMany(ClubUserRole, { foreignKey: 'clubRoleId', as: 'assignments' });
ClubUserRole.belongsTo(ClubRole, { foreignKey: 'clubRoleId', as: 'role' });
Club.hasMany(ClubUserRole, { foreignKey: 'clubId', as: 'clubUserRoles' });
ClubUserRole.belongsTo(Club, { foreignKey: 'clubId', as: 'club', constraints: false });
User.hasMany(ClubUserRole, { foreignKey: 'userId', as: 'clubRoleAssignments' });
ClubUserRole.belongsTo(User, { foreignKey: 'userId', as: 'user', constraints: false });
Club.hasMany(ClubCommunicationThread, { foreignKey: 'clubId', as: 'communicationThreads' });
ClubCommunicationThread.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
User.hasMany(ClubCommunicationThread, { foreignKey: 'createdByUserId', as: 'createdCommunicationThreads' });
ClubCommunicationThread.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
ClubCommunicationThread.belongsTo(ClubDistributionGroup, { foreignKey: 'distributionGroupId', as: 'distributionGroup', constraints: false });
ClubDistributionGroup.hasMany(ClubCommunicationThread, { foreignKey: 'distributionGroupId', as: 'threads' });
ClubCommunicationThread.belongsTo(Member, { foreignKey: 'recipientMemberId', as: 'recipientMember', constraints: false });
Member.hasMany(ClubCommunicationThread, { foreignKey: 'recipientMemberId', as: 'communicationThreads' });
Club.hasMany(ClubCommunicationMessage, { foreignKey: 'clubId', as: 'communicationMessages' });
ClubCommunicationMessage.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubCommunicationThread.hasMany(ClubCommunicationMessage, { foreignKey: 'threadId', as: 'messages' });
ClubCommunicationMessage.belongsTo(ClubCommunicationThread, { foreignKey: 'threadId', as: 'thread' });
User.hasMany(ClubCommunicationMessage, { foreignKey: 'createdByUserId', as: 'createdCommunicationMessages' });
ClubCommunicationMessage.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
Club.hasMany(ClubCommunicationTemplate, { foreignKey: 'clubId', as: 'communicationTemplates' });
ClubCommunicationTemplate.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
Club.hasMany(ClubCommunicationRecipient, { foreignKey: 'clubId', as: 'communicationRecipients' });
ClubCommunicationRecipient.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubCommunicationThread.hasMany(ClubCommunicationRecipient, { foreignKey: 'threadId', as: 'recipients' });
ClubCommunicationRecipient.belongsTo(ClubCommunicationThread, { foreignKey: 'threadId', as: 'thread' });
Member.hasMany(ClubCommunicationRecipient, { foreignKey: 'memberId', as: 'communicationRecipientEntries' });
ClubCommunicationRecipient.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
Club.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'clubId', as: 'communicationDeliveryLogs' });
ClubCommunicationDeliveryLog.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubCommunicationThread.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'threadId', as: 'deliveryLogs' });
ClubCommunicationDeliveryLog.belongsTo(ClubCommunicationThread, { foreignKey: 'threadId', as: 'thread' });
ClubCommunicationRecipient.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'recipientId', as: 'deliveryLogs' });
ClubCommunicationDeliveryLog.belongsTo(ClubCommunicationRecipient, { foreignKey: 'recipientId', as: 'recipient', constraints: false });
User.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'createdByUserId', as: 'createdCommunicationDeliveryLogs' });
ClubCommunicationDeliveryLog.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
Club.hasMany(ClubDistributionGroup, { foreignKey: 'clubId', as: 'distributionGroups' });
ClubDistributionGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
ClubDistributionGroup.hasMany(ClubDistributionGroupMember, { foreignKey: 'groupId', as: 'memberships' });
ClubDistributionGroupMember.belongsTo(ClubDistributionGroup, { foreignKey: 'groupId', as: 'group' });
Member.hasMany(ClubDistributionGroupMember, { foreignKey: 'memberId', as: 'distributionGroupMemberships' });
ClubDistributionGroupMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
export {
User,
Log,
@@ -473,6 +615,8 @@ export {
ClubTeam,
ClubTeamMember,
TeamDocument,
Season,
Location,
Group,
GroupActivity,
Tournament,
@@ -483,6 +627,8 @@ export {
TournamentResult,
ExternalTournamentParticipant,
TournamentPairing,
TournamentStage,
TournamentStageAdvancement,
Accident,
UserToken,
OfficialTournament,
@@ -504,6 +650,9 @@ export {
BillingDocument,
BillingDocumentValue,
BillingUserSetting,
ClubDocument,
ClubDocumentVersion,
ClubDocumentLink,
FriendlyMatch,
FriendlyMatchShared,
FriendlyMatchInvitation,
@@ -517,4 +666,24 @@ export {
ClubDisabledPresetGroup,
TrainingTime,
ClubVenue,
ClubRequest,
ClubRequestNote,
ClubSepaMandate,
ClubPaymentClaim,
ClubAccount,
ClubAccountTransaction,
ClubInvoiceParty,
ClubInvoice,
ClubInvoiceItem,
ClubTask,
ClubTaskSuppression,
ClubRole,
ClubUserRole,
ClubCommunicationThread,
ClubCommunicationMessage,
ClubCommunicationRecipient,
ClubCommunicationDeliveryLog,
ClubCommunicationTemplate,
ClubDistributionGroup,
ClubDistributionGroupMember,
};

View File

@@ -4,6 +4,7 @@ import {
createClubCalendarEvent,
deleteClubCalendarEvent,
listClubCalendarEvents,
updateClubCalendarEvent,
} from '../controllers/calendarEventController.js';
const router = express.Router();
@@ -11,6 +12,7 @@ router.use(authenticate);
router.get('/:clubId', listClubCalendarEvents);
router.post('/:clubId', createClubCalendarEvent);
router.put('/:clubId/:eventId', updateClubCalendarEvent);
router.delete('/:clubId/:eventId', deleteClubCalendarEvent);
export default router;

View File

@@ -0,0 +1,19 @@
import express from 'express';
import clubAccountController from '../controllers/clubAccountController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', authorize('finance_accounts', 'read'), clubAccountController.listClubAccounts);
router.post('/:clubId', authorize('finance_accounts', 'write'), clubAccountController.createClubAccount);
router.put('/:clubId/:accountId', authorize('finance_accounts', 'write'), clubAccountController.updateClubAccount);
router.patch('/:clubId/:accountId/status', authorize('finance_accounts', 'write'), clubAccountController.updateClubAccountStatus);
router.delete('/:clubId/:accountId', authorize('finance_accounts', 'write'), clubAccountController.deleteClubAccount);
router.post('/:clubId/transactions', authorize('finance_accounts', 'write'), clubAccountController.createTransaction);
router.put('/:clubId/transactions/:transactionId', authorize('finance_accounts', 'write'), clubAccountController.updateTransaction);
router.delete('/:clubId/transactions/:transactionId', authorize('finance_accounts', 'write'), clubAccountController.deleteTransaction);
export default router;

View File

@@ -0,0 +1,11 @@
import express from 'express';
import clubArchiveController from '../controllers/clubArchiveController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', authorize('archive', 'read'), clubArchiveController.getClubArchive);
export default router;

View File

@@ -0,0 +1,24 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import clubCommunicationController from '../controllers/clubCommunicationController.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', authorize('communication', 'read'), clubCommunicationController.list);
router.post('/:clubId/threads', authorize('communication', 'write'), clubCommunicationController.createThread);
router.put('/:clubId/threads/:threadId', authorize('communication', 'write'), clubCommunicationController.updateThread);
router.delete('/:clubId/threads/:threadId', authorize('communication', 'write'), clubCommunicationController.deleteThread);
router.post('/:clubId/threads/:threadId/messages', authorize('communication', 'write'), clubCommunicationController.addMessage);
router.patch('/:clubId/threads/:threadId/send', authorize('communication', 'write'), clubCommunicationController.sendThread);
router.post('/:clubId/groups', authorize('communication', 'write'), clubCommunicationController.createGroup);
router.put('/:clubId/groups/:groupId', authorize('communication', 'write'), clubCommunicationController.updateGroup);
router.delete('/:clubId/groups/:groupId', authorize('communication', 'write'), clubCommunicationController.deleteGroup);
router.post('/:clubId/templates', authorize('communication', 'write'), clubCommunicationController.createTemplate);
router.put('/:clubId/templates/:templateId', authorize('communication', 'write'), clubCommunicationController.updateTemplate);
router.delete('/:clubId/templates/:templateId', authorize('communication', 'write'), clubCommunicationController.deleteTemplate);
export default router;

View File

@@ -0,0 +1,10 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import { getClubDashboard } from '../controllers/clubDashboardController.js';
const router = express.Router();
router.get('/:clubId', authenticate, authorize('members', 'read'), getClubDashboard);
export default router;

View File

@@ -0,0 +1,19 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import clubDocumentController, {
uploadMiddleware,
} from '../controllers/clubDocumentController.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', authorize('settings', 'read'), clubDocumentController.listClubDocuments);
router.get('/:clubId/:documentId/download', authorize('settings', 'read'), clubDocumentController.downloadClubDocument);
router.post('/:clubId', authorize('settings', 'write'), uploadMiddleware, clubDocumentController.createClubDocument);
router.put('/:clubId/:documentId', authorize('settings', 'write'), uploadMiddleware, clubDocumentController.updateClubDocument);
router.patch('/:clubId/:documentId/archive', authorize('settings', 'write'), clubDocumentController.archiveClubDocument);
router.delete('/:clubId/:documentId', authorize('settings', 'write'), clubDocumentController.deleteClubDocument);
export default router;

View File

@@ -0,0 +1,21 @@
import express from 'express';
import clubInvoiceController from '../controllers/clubInvoiceController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', authorize('finance_invoices', 'read'), clubInvoiceController.listClubInvoices);
router.post('/:clubId/parties', authorize('finance_invoices', 'write'), clubInvoiceController.createInvoiceParty);
router.put('/:clubId/parties/:partyId', authorize('finance_invoices', 'write'), clubInvoiceController.updateInvoiceParty);
router.delete('/:clubId/parties/:partyId', authorize('finance_invoices', 'write'), clubInvoiceController.deleteInvoiceParty);
router.post('/:clubId', authorize('finance_invoices', 'write'), clubInvoiceController.createInvoice);
router.put('/:clubId/:invoiceId', authorize('finance_invoices', 'write'), clubInvoiceController.updateInvoice);
router.patch('/:clubId/:invoiceId/status', authorize('finance_invoices', 'write'), clubInvoiceController.updateInvoiceStatus);
router.delete('/:clubId/:invoiceId', authorize('finance_invoices', 'write'), clubInvoiceController.deleteInvoice);
export default router;

View File

@@ -0,0 +1,17 @@
import express from 'express';
import clubPaymentClaimController from '../controllers/clubPaymentClaimController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', authorize('finance_accounts', 'read'), clubPaymentClaimController.list);
router.post('/:clubId', authorize('finance_accounts', 'write'), clubPaymentClaimController.create);
router.put('/:clubId/:claimId', authorize('finance_accounts', 'write'), clubPaymentClaimController.update);
router.patch('/:clubId/:claimId/payment', authorize('finance_accounts', 'write'), clubPaymentClaimController.registerPayment);
router.patch('/:clubId/:claimId/status', authorize('finance_accounts', 'write'), clubPaymentClaimController.updateStatus);
router.delete('/:clubId/:claimId', authorize('finance_accounts', 'write'), clubPaymentClaimController.delete);
export default router;

View File

@@ -0,0 +1,20 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import {
addClubRequestNote,
createClubRequest,
listClubRequests,
updateClubRequest,
updateClubRequestStatus,
} from '../controllers/clubRequestController.js';
const router = express.Router();
router.get('/:clubId', authenticate, authorize('requests', 'read'), listClubRequests);
router.post('/:clubId', authenticate, authorize('requests', 'write'), createClubRequest);
router.put('/:clubId/:requestId', authenticate, authorize('requests', 'write'), updateClubRequest);
router.patch('/:clubId/:requestId/status', authenticate, authorize('requests', 'write'), updateClubRequestStatus);
router.post('/:clubId/:requestId/notes', authenticate, authorize('requests', 'write'), addClubRequestNote);
export default router;

View File

@@ -0,0 +1,11 @@
import express from 'express';
import clubStatisticsController from '../controllers/clubStatisticsController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
router.use(authenticate);
router.get('/:clubId', authorize('statistics', 'read'), clubStatisticsController.getClubStatistics);
export default router;

View File

@@ -0,0 +1,24 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import {
createClubTask,
deleteClubTask,
dismissAutomatedClubTaskSuggestion,
listClubTasks,
materializeAutomatedClubTasks,
updateClubTask,
updateClubTaskStatus,
} from '../controllers/clubTaskController.js';
const router = express.Router();
router.get('/:clubId', authenticate, authorize('tasks', 'read'), listClubTasks);
router.post('/:clubId', authenticate, authorize('tasks', 'write'), createClubTask);
router.post('/:clubId/materialize', authenticate, authorize('tasks', 'write'), materializeAutomatedClubTasks);
router.post('/:clubId/dismiss-suggestion', authenticate, authorize('tasks', 'write'), dismissAutomatedClubTaskSuggestion);
router.put('/:clubId/:taskId', authenticate, authorize('tasks', 'write'), updateClubTask);
router.patch('/:clubId/:taskId/status', authenticate, authorize('tasks', 'write'), updateClubTaskStatus);
router.delete('/:clubId/:taskId', authenticate, authorize('tasks', 'write'), deleteClubTask);
export default router;

View File

@@ -2,6 +2,8 @@ import {
getClubMembers,
getWaitingApprovals,
setClubMembers,
getMemberSepaMandate,
saveMemberSepaMandate,
getMemberPlayInterests,
setMemberPlayInterest,
uploadMemberImage,
@@ -37,6 +39,8 @@ router.post('/image/:clubId/:memberId/:imageId/primary', authenticate, authorize
router.get('/get/:id/:showAll', authenticate, authorize('members', 'read'), getClubMembers);
router.get('/gallery/:clubId', authenticate, authorize('members', 'read'), generateMemberGallery);
router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers);
router.get('/sepa/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberSepaMandate);
router.put('/sepa/:clubId/:memberId', authenticate, authorize('members', 'write'), saveMemberSepaMandate);
router.get('/play-interest/:clubId', authenticate, authorize('members', 'read'), getMemberPlayInterests);
router.post('/play-interest/:clubId', authenticate, authorize('members', 'write'), setMemberPlayInterest);
router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals);

View File

@@ -22,6 +22,12 @@ router.get('/roles/available', authenticate, permissionController.getAvailableRo
// Get permission structure (no club context needed)
router.get('/structure/all', authenticate, permissionController.getPermissionStructure);
// Get and manage club roles
router.get('/:clubId/roles', authenticate, authorize('permissions', 'read'), permissionController.getClubRoles);
router.post('/:clubId/roles', authenticate, authorize('permissions', 'write'), permissionController.createClubRole);
router.put('/:clubId/roles/:roleId', authenticate, authorize('permissions', 'write'), permissionController.updateClubRole);
router.delete('/:clubId/roles/:roleId', authenticate, authorize('permissions', 'write'), permissionController.deleteClubRole);
// Get current user's permissions for a club (no authorization check - needed to load permissions)
router.get('/:clubId', authenticate, permissionController.getUserPermissions);
@@ -30,6 +36,7 @@ router.get('/:clubId/members', authenticate, authorize('permissions', 'read'), p
// Update user role (admin only)
router.put('/:clubId/user/:userId/role', authenticate, authorize('permissions', 'write'), permissionController.updateUserRole);
router.put('/:clubId/user/:userId/roles', authenticate, authorize('permissions', 'write'), permissionController.updateUserRoles);
// Update user permissions (admin only)
router.put('/:clubId/user/:userId/permissions', authenticate, authorize('permissions', 'write'), permissionController.updateUserPermissions);
@@ -38,4 +45,3 @@ router.put('/:clubId/user/:userId/permissions', authenticate, authorize('permiss
router.put('/:clubId/user/:userId/status', authenticate, authorize('permissions', 'write'), permissionController.updateUserStatus);
export default router;

View File

@@ -16,7 +16,7 @@ import {
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest,
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation
, FriendlyMatchShared, FriendlyMatchInvitation
, CalendarEvent, ClubVenue
, CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubAccountTransaction, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole, ClubCommunicationThread, ClubCommunicationMessage, ClubCommunicationRecipient, ClubCommunicationDeliveryLog, ClubCommunicationTemplate, ClubDistributionGroup, ClubDistributionGroupMember
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -68,6 +68,16 @@ import friendlyMatchInvitationRoutes from './routes/friendlyMatchInvitationRoute
import calendarRoutes from './routes/calendarRoutes.js';
import calendarEventRoutes from './routes/calendarEventRoutes.js';
import mobileFeedbackRoutes from './routes/mobileFeedbackRoutes.js';
import clubRequestRoutes from './routes/clubRequestRoutes.js';
import clubDashboardRoutes from './routes/clubDashboardRoutes.js';
import clubTaskRoutes from './routes/clubTaskRoutes.js';
import clubStatisticsRoutes from './routes/clubStatisticsRoutes.js';
import clubArchiveRoutes from './routes/clubArchiveRoutes.js';
import clubAccountRoutes from './routes/clubAccountRoutes.js';
import clubInvoiceRoutes from './routes/clubInvoiceRoutes.js';
import clubPaymentClaimRoutes from './routes/clubPaymentClaimRoutes.js';
import clubCommunicationRoutes from './routes/clubCommunicationRoutes.js';
import clubDocumentRoutes from './routes/clubDocumentRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -368,6 +378,16 @@ app.use('/api/friendly-match-invitations', friendlyMatchInvitationRoutes);
app.use('/api/calendar', calendarRoutes);
app.use('/api/calendar-events', calendarEventRoutes);
app.use('/api/mobile-feedback', mobileFeedbackRoutes);
app.use('/api/club-requests', clubRequestRoutes);
app.use('/api/club-dashboard', clubDashboardRoutes);
app.use('/api/club-tasks', clubTaskRoutes);
app.use('/api/club-statistics', clubStatisticsRoutes);
app.use('/api/club-archive', clubArchiveRoutes);
app.use('/api/club-accounts', clubAccountRoutes);
app.use('/api/club-invoices', clubInvoiceRoutes);
app.use('/api/club-payment-claims', clubPaymentClaimRoutes);
app.use('/api/club-communication', clubCommunicationRoutes);
app.use('/api/club-documents', clubDocumentRoutes);
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {
@@ -571,6 +591,20 @@ app.use((err, req, res, next) => {
await safeSync(Club);
await safeSync(ClubVenue);
await safeSync(UserClub);
await safeSync(ClubRole);
await safeSync(ClubUserRole);
await safeSync(ClubAccount);
await safeSync(ClubAccountTransaction);
await safeSync(ClubInvoiceParty);
await safeSync(ClubInvoice);
await safeSync(ClubInvoiceItem);
await safeSync(ClubDistributionGroup);
await safeSync(ClubDistributionGroupMember);
await safeSync(ClubCommunicationThread);
await safeSync(ClubCommunicationMessage);
await safeSync(ClubCommunicationRecipient);
await safeSync(ClubCommunicationDeliveryLog);
await safeSync(ClubCommunicationTemplate);
await safeSync(Log);
await safeSync(Member);
await safeSync(DiaryDate);

View File

@@ -17,25 +17,31 @@ class CalendarEventService {
});
}
async createClubEvent(userToken, clubId, payload) {
async createClubEvent(userToken, clubId, payload, userId = null) {
await checkAccess(userToken, clubId);
const title = String(payload?.title || '').trim();
if (!title) throw new HttpError('Titel fehlt', 400);
const startDate = this.normalizeDate(payload?.startDate);
const endDate = this.normalizeDate(payload?.endDate || payload?.startDate);
if (!startDate || !endDate) throw new HttpError('Ungültiges Datum', 400);
if (startDate > endDate) throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400);
const normalized = this.normalizePayload(payload);
this.validatePayload(normalized);
return await CalendarEvent.create({
clubId,
title,
startDate,
endDate,
category: payload?.category ? String(payload.category).trim().slice(0, 64) : null,
notes: payload?.notes ? String(payload.notes).trim() : null,
...normalized,
organizerUserId: Number(userId || payload?.organizerUserId || 0) || null,
});
}
async updateClubEvent(userToken, clubId, eventId, payload, userId = null) {
await checkAccess(userToken, clubId);
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
if (!event) throw new HttpError('Event nicht gefunden', 404);
const normalized = this.normalizePayload(payload);
this.validatePayload(normalized);
await event.update({
...normalized,
organizerUserId: Number(userId || payload?.organizerUserId || event.organizerUserId || 0) || null,
});
return event;
}
async deleteClubEvent(userToken, clubId, eventId) {
await checkAccess(userToken, clubId);
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
@@ -54,6 +60,34 @@ class CalendarEventService {
const text = String(date || '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
}
normalizePayload(payload = {}) {
return {
title: String(payload?.title || '').trim().slice(0, 255),
eventType: ['club_event', 'meeting', 'tournament', 'social', 'workshop', 'other'].includes(payload?.eventType)
? payload.eventType
: 'club_event',
status: ['planning', 'invited', 'confirmed', 'done', 'cancelled'].includes(payload?.status)
? payload.status
: 'planning',
description: payload?.description ? String(payload.description).trim() : null,
location: payload?.location ? String(payload.location).trim().slice(0, 255) : null,
startDate: this.normalizeDate(payload?.startDate),
endDate: this.normalizeDate(payload?.endDate || payload?.startDate),
registrationDeadline: this.normalizeDate(payload?.registrationDeadline),
category: payload?.category ? String(payload.category).trim().slice(0, 64) : null,
notes: payload?.notes ? String(payload.notes).trim() : null,
};
}
validatePayload(payload) {
if (!payload.title) throw new HttpError('Titel fehlt', 400);
if (!payload.startDate || !payload.endDate) throw new HttpError('Ungültiges Datum', 400);
if (payload.startDate > payload.endDate) throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400);
if (payload.registrationDeadline && payload.registrationDeadline > payload.startDate) {
throw new HttpError('Anmeldefrist darf nicht nach dem Startdatum liegen', 400);
}
}
}
export default new CalendarEventService();

View File

@@ -0,0 +1,594 @@
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';
import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.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']);
const ACCOUNT_STATUSES = new Set(['active', 'inactive', 'archived']);
function trimText(value, maxLength = null) {
const normalized = String(value || '').trim();
if (!normalized) return null;
return maxLength ? normalized.slice(0, maxLength) : normalized;
}
function normalizeIban(value) {
const normalized = String(value || '').replace(/\s+/g, '').trim().toUpperCase();
return normalized || null;
}
function normalizeBic(value) {
const normalized = String(value || '').replace(/\s+/g, '').trim().toUpperCase();
return normalized || null;
}
function normalizePayload(payload = {}) {
const accountType = ACCOUNT_TYPES.has(payload.accountType) ? payload.accountType : 'bank';
const usageType = ACCOUNT_USAGE_TYPES.has(payload.usageType) ? payload.usageType : 'general';
const status = ACCOUNT_STATUSES.has(payload.status) ? payload.status : 'active';
return {
name: trimText(payload.name, 160),
accountHolder: trimText(payload.accountHolder, 255),
bankName: trimText(payload.bankName, 160),
iban: normalizeIban(payload.iban),
bic: normalizeBic(payload.bic),
accountType,
usageType,
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
allowSepaCollections: Boolean(payload.allowSepaCollections),
allowOutgoingPayments: Boolean(payload.allowOutgoingPayments),
isDefault: Boolean(payload.isDefault),
status,
notes: trimText(payload.notes),
sortOrder: Number.isFinite(Number(payload.sortOrder)) ? Number(payload.sortOrder) : 0,
};
}
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 (!(await hasClubPaymentClaimPaidAmountCentsColumn())) {
return 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 buildTransactionIncludes(includePaymentClaims) {
return includePaymentClaims
? [
{ model: ClubAccount, as: 'account', required: false },
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
]
: [
{ model: ClubAccount, as: 'account', required: false },
];
}
function validatePayload(payload) {
if (!payload.name) {
const error = new Error('Kontobezeichnung ist erforderlich.');
error.status = 400;
throw error;
}
if (payload.allowSepaCollections && payload.accountType !== 'bank') {
const error = new Error('SEPA-Einzüge sind nur für Bankkonten möglich.');
error.status = 400;
throw error;
}
if (payload.allowSepaCollections && !payload.iban) {
const error = new Error('Für SEPA-Einzüge muss eine IBAN hinterlegt sein.');
error.status = 400;
throw error;
}
if (payload.status === 'archived' && payload.isDefault) {
const error = new Error('Ein archiviertes Konto kann nicht das Standardkonto sein.');
error.status = 400;
throw error;
}
}
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 },
{
where: {
clubId,
id: { [Op.ne]: accountId },
},
transaction,
}
);
}
async function ensureFallbackDefault(clubId, transaction) {
const existingDefault = await ClubAccount.findOne({
where: {
clubId,
isDefault: true,
status: { [Op.ne]: 'archived' },
},
transaction,
});
if (existingDefault) {
return;
}
const fallback = await ClubAccount.findOne({
where: {
clubId,
status: { [Op.ne]: 'archived' },
},
order: [['sortOrder', 'ASC'], ['createdAt', 'ASC']],
transaction,
});
if (fallback) {
await fallback.update({ isDefault: true }, { transaction });
}
}
class ClubAccountService {
async listClubAccounts(clubId) {
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
const [accounts, transactions] = await Promise.all([
ClubAccount.findAll({
where: { clubId },
order: [
['isDefault', 'DESC'],
['status', 'ASC'],
['sortOrder', 'ASC'],
['name', 'ASC'],
],
}),
ClubAccountTransaction.findAll({
where: { clubId },
include: buildTransactionIncludes(includePaymentClaims),
order: [['bookingDate', 'DESC'], ['createdAt', 'DESC']],
limit: 250,
}),
]);
return {
accounts,
transactions,
};
}
async createTransaction(clubId, userId, payload) {
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
const normalized = normalizeTransactionPayload(payload);
if (!includePaymentClaims) {
normalized.paymentClaimId = null;
}
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 (includePaymentClaims && 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 if (includePaymentClaims) {
matchedClaim = await findBestPaymentClaimMatch(clubId, normalized, dbTransaction);
}
const transactionPayload = {
clubId,
createdByUserId: userId || null,
...normalized,
paymentClaimId: includePaymentClaims ? (matchedClaim ? matchedClaim.id : normalized.paymentClaimId) : null,
bookingType: matchedClaim ? 'payment_claim' : normalized.bookingType,
};
const transaction = await ClubAccountTransaction.create(transactionPayload, { transaction: dbTransaction });
if (includePaymentClaims && matchedClaim) {
await clubPaymentClaimService.applyPaymentToClaim(
clubId,
matchedClaim,
{
amountCents: Number(transaction.amountCents || 0),
},
dbTransaction
);
await clubPaymentClaimService.reconcileFromTransactions(clubId, matchedClaim.id, dbTransaction);
}
return transaction.reload({
include: buildTransactionIncludes(includePaymentClaims),
transaction: dbTransaction,
});
});
}
async updateTransaction(clubId, transactionId, payload) {
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
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);
if (!includePaymentClaims) {
normalized.paymentClaimId = null;
}
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);
if (includePaymentClaims) {
const claimIdsToReconcile = new Set([previousPaymentClaimId, normalized.paymentClaimId || null].filter(Boolean));
for (const claimId of claimIdsToReconcile) {
await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId);
}
}
return transactionRow.reload({
include: buildTransactionIncludes(includePaymentClaims),
});
}
async deleteTransaction(clubId, transactionId) {
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
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 (includePaymentClaims && claimId) {
await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId);
}
return { success: true };
}
async listAccountTransactions(clubId, accountId = null) {
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
const where = { clubId };
if (accountId) {
where.accountId = accountId;
}
return ClubAccountTransaction.findAll({
where,
include: buildTransactionIncludes(includePaymentClaims),
order: [
['bookingDate', 'DESC'],
['createdAt', 'DESC'],
],
});
}
async createClubAccount(clubId, payload) {
const normalized = normalizePayload(payload);
validatePayload(normalized);
return sequelize.transaction(async (transaction) => {
const account = await ClubAccount.create({
clubId,
...normalized,
archivedAt: normalized.status === 'archived' ? new Date() : null,
}, { transaction });
if (normalized.isDefault) {
await ensureSingleDefault(clubId, account.id, transaction);
} else {
await ensureFallbackDefault(clubId, transaction);
}
return account.reload({ transaction });
});
}
async updateClubAccount(clubId, accountId, payload) {
const account = await ClubAccount.findOne({
where: { id: accountId, clubId },
});
if (!account) {
const error = new Error('Konto wurde nicht gefunden.');
error.status = 404;
throw error;
}
const normalized = normalizePayload(payload);
validatePayload(normalized);
return sequelize.transaction(async (transaction) => {
await account.update({
...normalized,
archivedAt: normalized.status === 'archived'
? (account.archivedAt || new Date())
: null,
}, { transaction });
if (normalized.isDefault) {
await ensureSingleDefault(clubId, account.id, transaction);
}
await ensureFallbackDefault(clubId, transaction);
return account.reload({ transaction });
});
}
async updateClubAccountStatus(clubId, accountId, status) {
if (!ACCOUNT_STATUSES.has(status)) {
const error = new Error('Ungültiger Kontostatus.');
error.status = 400;
throw error;
}
const account = await ClubAccount.findOne({
where: { id: accountId, clubId },
});
if (!account) {
const error = new Error('Konto wurde nicht gefunden.');
error.status = 404;
throw error;
}
return sequelize.transaction(async (transaction) => {
const updatePayload = {
status,
archivedAt: status === 'archived' ? (account.archivedAt || new Date()) : null,
};
if (status === 'archived') {
updatePayload.isDefault = false;
}
await account.update(updatePayload, { transaction });
await ensureFallbackDefault(clubId, transaction);
return account.reload({ transaction });
});
}
async deleteClubAccount(clubId, accountId) {
const account = await ClubAccount.findOne({
where: { id: accountId, clubId },
});
if (!account) {
const error = new Error('Konto wurde nicht gefunden.');
error.status = 404;
throw error;
}
await sequelize.transaction(async (transaction) => {
await account.destroy({ transaction });
await ensureFallbackDefault(clubId, transaction);
});
}
}
export default new ClubAccountService();

View File

@@ -0,0 +1,159 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import { ClubPaymentClaim, ClubRequest, ClubTask, Member } from '../models/index.js';
import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js';
const DEFAULT_LIMIT = 50;
function formatMemberName(member) {
return [member?.firstName, member?.lastName].filter(Boolean).join(' ').trim() || `Mitglied #${member?.id}`;
}
async function loadAvailableTables() {
const tables = await sequelize.getQueryInterface().showAllTables();
return new Set(
tables
.map((table) => (typeof table === 'string' ? table : Object.values(table || {})[0]))
.filter(Boolean)
.map((table) => String(table).toLowerCase())
);
}
async function loadOptionalTableData(availableTables, tableName, loader, fallbackValue = []) {
if (!availableTables.has(String(tableName).toLowerCase())) {
return fallbackValue;
}
return loader();
}
class ClubArchiveService {
async getClubArchive(clubIdRaw) {
const clubId = Number.parseInt(clubIdRaw, 10);
if (!Number.isFinite(clubId)) {
const error = new Error('Ungültige clubId');
error.status = 400;
throw error;
}
const availableTables = await loadAvailableTables();
const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn();
const members = await Member.findAll({
where: { clubId },
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
});
const memberMap = new Map(members.map((member) => [Number(member.id), member]));
const archivedRequests = await loadOptionalTableData(
availableTables,
'club_requests',
() => ClubRequest.findAll({
where: {
clubId,
status: 'archived',
},
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
limit: DEFAULT_LIMIT,
})
);
const archivedTasks = await loadOptionalTableData(
availableTables,
'club_tasks',
() => ClubTask.findAll({
where: {
clubId,
status: 'archived',
},
order: [['archivedAt', 'DESC'], ['updatedAt', 'DESC']],
limit: DEFAULT_LIMIT,
})
);
const archivedClaims = hasPaidAmountCentsColumn
? await loadOptionalTableData(
availableTables,
'club_payment_claims',
() => ClubPaymentClaim.findAll({
where: {
clubId,
[Op.or]: [
{ archivedAt: { [Op.not]: null } },
{ status: { [Op.in]: ['written_off', 'cancelled'] } },
],
},
order: [['archivedAt', 'DESC'], ['updatedAt', 'DESC']],
limit: DEFAULT_LIMIT,
})
)
: [];
const inactiveMembers = members
.filter((member) => !member.active)
.slice(0, DEFAULT_LIMIT)
.map((member) => ({
id: member.id,
firstName: member.firstName || '',
lastName: member.lastName || '',
displayName: formatMemberName(member),
email: member.email || '',
city: member.city || '',
createdAt: member.createdAt || null,
updatedAt: member.updatedAt || null,
}));
return {
summary: {
inactiveMembers: members.filter((member) => !member.active).length,
archivedRequests: archivedRequests.length,
archivedTasks: archivedTasks.length,
archivedClaims: archivedClaims.length,
},
inactiveMembers,
archivedRequests: archivedRequests.map((entry) => ({
id: entry.id,
requestType: entry.requestType,
status: entry.status,
subject: entry.subject || '',
personName: [entry.firstName, entry.lastName].filter(Boolean).join(' ').trim(),
email: entry.email || '',
updatedAt: entry.updatedAt || null,
createdAt: entry.createdAt || null,
})),
archivedTasks: archivedTasks.map((entry) => ({
id: entry.id,
title: entry.title || '',
taskType: entry.taskType || '',
status: entry.status,
priority: entry.priority || 'normal',
dueAt: entry.dueAt || null,
archivedAt: entry.archivedAt || null,
updatedAt: entry.updatedAt || null,
})),
archivedClaims: archivedClaims.map((entry) => {
const member = memberMap.get(Number(entry.memberId));
return {
id: entry.id,
memberId: entry.memberId || null,
memberName: member ? formatMemberName(member) : '',
claimType: entry.claimType || 'membership_fee',
status: entry.status,
dueOn: entry.dueOn || null,
amountCents: Number(entry.amountCents || 0),
currencyCode: entry.currencyCode || 'EUR',
settledAt: entry.settledAt || null,
archivedAt: entry.archivedAt || null,
updatedAt: entry.updatedAt || null,
};
}),
notes: [
'Das Vereinsarchiv zeigt inaktive Mitglieder sowie archivierte oder abgeschlossene Vereinsvorgänge.',
'Ein zentrales Dokumentenarchiv wird ergänzt, sobald Dokumente und Rechnungen produktiv archiviert werden.',
],
};
}
}
export default new ClubArchiveService();

View File

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

View File

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

View File

@@ -0,0 +1,482 @@
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';
import ClubAccount from '../models/ClubAccount.js';
const INVOICE_DIRECTIONS = new Set(['incoming', 'outgoing']);
const INVOICE_STATUSES = new Set(['draft', 'issued', 'partially_paid', 'paid', 'cancelled', 'archived']);
const PARTY_TYPES = new Set(['customer', 'supplier', 'sponsor', 'other']);
const INVOICE_TYPES = new Set(['membership_fee', 'course_fee', 'sponsoring', 'material', 'service', 'expense', 'other']);
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 parseNumber(value, fallback = 0) {
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : fallback;
}
function roundToInt(value) {
return Math.round(Number(value) || 0);
}
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),
street: trimText(payload.street, 255),
postalCode: trimText(payload.postalCode, 24),
city: trimText(payload.city, 120),
countryCode: trimText(payload.countryCode, 2)?.toUpperCase() || 'DE',
iban: trimText(payload.iban, 34)?.replace(/\s+/g, '').toUpperCase() || null,
bic: trimText(payload.bic, 11)?.replace(/\s+/g, '').toUpperCase() || null,
taxIdentifier: trimText(payload.taxIdentifier, 64),
notes: trimText(payload.notes),
};
}
function normalizeInvoiceItems(items = []) {
return (Array.isArray(items) ? items : [])
.map((item, index) => {
const quantity = parseNumber(item.quantity, 1);
const unitPriceCents = roundToInt(item.unitPriceCents);
const taxRate = parseNumber(item.taxRate, 0);
const netLineCents = roundToInt(quantity * unitPriceCents);
const taxLineCents = roundToInt(netLineCents * (taxRate / 100));
const totalCents = netLineCents + taxLineCents;
return {
lineNo: index + 1,
description: trimText(item.description) || '',
quantity,
unitPriceCents,
taxRate,
netLineCents,
taxLineCents,
totalCents,
};
})
.filter((item) => item.description);
}
function normalizeInvoicePayload(payload = {}) {
return {
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',
externalReference: trimText(payload.externalReference, 255),
partyId: payload.partyId ? Number(payload.partyId) : null,
accountId: payload.accountId ? Number(payload.accountId) : null,
issuedOn: normalizeDate(payload.issuedOn),
dueOn: normalizeDate(payload.dueOn),
paidOn: normalizeDate(payload.paidOn),
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
description: trimText(payload.description),
items: normalizeInvoiceItems(payload.items),
};
}
function validateInvoicePayload(payload) {
if (!payload.partyId) {
const error = new Error('Bitte eine Rechnungspartei auswählen.');
error.status = 400;
throw error;
}
if (payload.items.length === 0) {
const error = new Error('Mindestens eine Rechnungsposition ist erforderlich.');
error.status = 400;
throw error;
}
}
function summarizeItems(items) {
return items.reduce((acc, item) => {
acc.netAmountCents += item.netLineCents;
acc.taxAmountCents += item.taxLineCents;
acc.grossAmountCents += item.totalCents;
return acc;
}, {
netAmountCents: 0,
taxAmountCents: 0,
grossAmountCents: 0,
});
}
function buildInvoiceNumber(prefix, nextNumber, referenceDate = new Date()) {
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}`;
}
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,
});
if (!club) {
const error = new Error('Verein wurde nicht gefunden.');
error.status = 404;
throw error;
}
const isIncoming = invoiceDirection === 'incoming';
const prefixField = isIncoming ? 'incomingInvoicePrefix' : 'outgoingInvoicePrefix';
const nextNumberField = isIncoming ? 'incomingInvoiceNextNumber' : 'outgoingInvoiceNextNumber';
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]: nextNumber + 1,
}, { transaction });
return invoiceNumber;
}
class ClubInvoiceService {
async listClubInvoices(clubId) {
const [club, parties, accounts, invoices] = await Promise.all([
Club.findByPk(clubId, {
attributes: [
'id',
'outgoingInvoicePrefix',
'outgoingInvoiceNextNumber',
'incomingInvoicePrefix',
'incomingInvoiceNextNumber',
],
}),
ClubInvoiceParty.findAll({
where: { clubId },
order: [['name', 'ASC']],
}),
ClubAccount.findAll({
where: { clubId },
order: [['isDefault', 'DESC'], ['name', 'ASC']],
}),
ClubInvoice.findAll({
where: { clubId },
include: [
{ model: ClubInvoiceParty, as: 'party', required: false },
{ model: ClubInvoiceItem, as: 'items', required: false },
{ model: ClubAccount, as: 'account', required: false },
],
order: [['updatedAt', 'DESC'], [{ model: ClubInvoiceItem, as: 'items' }, 'lineNo', 'ASC']],
}),
]);
return {
parties,
accounts,
invoices,
settings: club ? {
outgoingInvoicePrefix: club.outgoingInvoicePrefix || 'RE',
outgoingInvoiceNextNumber: club.outgoingInvoiceNextNumber || 1,
incomingInvoicePrefix: club.incomingInvoicePrefix || 'EI',
incomingInvoiceNextNumber: club.incomingInvoiceNextNumber || 1,
} : null,
};
}
async createInvoiceParty(clubId, payload) {
const normalized = normalizePartyPayload(payload);
if (!normalized.name) {
const error = new Error('Name der Rechnungspartei ist erforderlich.');
error.status = 400;
throw error;
}
return ClubInvoiceParty.create({ clubId, ...normalized });
}
async updateInvoiceParty(clubId, partyId, payload) {
const party = await ClubInvoiceParty.findOne({ where: { id: partyId, clubId } });
if (!party) {
const error = new Error('Rechnungspartei wurde nicht gefunden.');
error.status = 404;
throw error;
}
const normalized = normalizePartyPayload(payload);
if (!normalized.name) {
const error = new Error('Name der Rechnungspartei ist erforderlich.');
error.status = 400;
throw error;
}
await party.update(normalized);
return party;
}
async deleteInvoiceParty(clubId, partyId) {
const party = await ClubInvoiceParty.findOne({ where: { id: partyId, clubId } });
if (!party) {
const error = new Error('Rechnungspartei wurde nicht gefunden.');
error.status = 404;
throw error;
}
const linkedCount = await ClubInvoice.count({ where: { clubId, partyId } });
if (linkedCount > 0) {
const error = new Error('Rechnungspartei kann nicht gelöscht werden, weil bereits Rechnungen darauf verweisen.');
error.status = 400;
throw error;
}
await party.destroy();
}
async createInvoice(clubId, userId, payload) {
const normalized = normalizeInvoicePayload(payload);
validateInvoicePayload(normalized);
const totals = summarizeItems(normalized.items);
return sequelize.transaction(async (transaction) => {
const invoiceNumber = await generateNextInvoiceNumber(
clubId,
normalized.invoiceDirection,
normalized.issuedOn,
transaction
);
const invoice = await ClubInvoice.create({
clubId,
createdByUserId: userId || null,
...normalized,
invoiceNumber,
...totals,
archivedAt: normalized.status === 'archived' ? new Date() : null,
}, { transaction });
await ClubInvoiceItem.bulkCreate(
normalized.items.map((item) => ({
invoiceId: invoice.id,
lineNo: item.lineNo,
description: item.description,
quantity: item.quantity,
unitPriceCents: item.unitPriceCents,
taxRate: item.taxRate,
totalCents: item.totalCents,
})),
{ transaction }
);
await syncInvoiceAccountTransaction(invoice, transaction);
return ClubInvoice.findOne({
where: { id: invoice.id, clubId },
include: [
{ model: ClubInvoiceParty, as: 'party', required: false },
{ model: ClubInvoiceItem, as: 'items', required: false },
{ model: ClubAccount, as: 'account', required: false },
],
transaction,
});
});
}
async updateInvoice(clubId, invoiceId, payload) {
const invoice = await ClubInvoice.findOne({ where: { id: invoiceId, clubId } });
if (!invoice) {
const error = new Error('Rechnung wurde nicht gefunden.');
error.status = 404;
throw error;
}
const normalized = normalizeInvoicePayload(payload);
validateInvoicePayload(normalized);
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 });
await ClubInvoiceItem.destroy({
where: { invoiceId: invoice.id },
transaction,
});
await ClubInvoiceItem.bulkCreate(
normalized.items.map((item) => ({
invoiceId: invoice.id,
lineNo: item.lineNo,
description: item.description,
quantity: item.quantity,
unitPriceCents: item.unitPriceCents,
taxRate: item.taxRate,
totalCents: item.totalCents,
})),
{ transaction }
);
await syncInvoiceAccountTransaction(invoice, transaction);
return ClubInvoice.findOne({
where: { id: invoice.id, clubId },
include: [
{ model: ClubInvoiceParty, as: 'party', required: false },
{ model: ClubInvoiceItem, as: 'items', required: false },
{ model: ClubAccount, as: 'account', required: false },
],
transaction,
});
});
}
async updateInvoiceStatus(clubId, invoiceId, status) {
if (!INVOICE_STATUSES.has(status)) {
const error = new Error('Ungültiger Rechnungsstatus.');
error.status = 400;
throw error;
}
const invoice = await ClubInvoice.findOne({ where: { id: invoiceId, clubId } });
if (!invoice) {
const error = new Error('Rechnung wurde nicht gefunden.');
error.status = 404;
throw error;
}
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.reload({
include: [
{ model: ClubInvoiceParty, as: 'party', required: false },
{ model: ClubInvoiceItem, as: 'items', required: false },
{ model: ClubAccount, as: 'account', required: false },
],
});
}
async deleteInvoice(clubId, invoiceId) {
const invoice = await ClubInvoice.findOne({ where: { id: invoiceId, clubId } });
if (!invoice) {
const error = new Error('Rechnung wurde nicht gefunden.');
error.status = 404;
throw error;
}
await sequelize.transaction(async (transaction) => {
await ClubAccountTransaction.destroy({
where: {
clubId,
invoiceId,
bookingType: 'invoice',
},
transaction,
});
await ClubInvoiceItem.destroy({ where: { invoiceId }, transaction });
await invoice.destroy({ transaction });
});
}
}
export default new ClubInvoiceService();

View File

@@ -0,0 +1,22 @@
import sequelize from '../database.js';
let hasPaidAmountCentsColumnPromise = null;
function isMissingTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE';
}
export async function hasClubPaymentClaimPaidAmountCentsColumn() {
if (!hasPaidAmountCentsColumnPromise) {
hasPaidAmountCentsColumnPromise = sequelize.getQueryInterface().describeTable('club_payment_claims')
.then((description) => Boolean(description?.paid_amount_cents))
.catch((error) => {
if (isMissingTableError(error)) {
return false;
}
throw error;
});
}
return hasPaidAmountCentsColumnPromise;
}

View File

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

View File

@@ -74,7 +74,12 @@ class ClubService {
autoFetchRankings,
countryCode,
stateCode,
memberDataQualityRequirements
memberDataQualityRequirements,
feeRules,
outgoingInvoicePrefix,
outgoingInvoiceNextNumber,
incomingInvoicePrefix,
incomingInvoiceNextNumber
}) {
await checkAccess(userToken, clubId);
const club = await Club.findByPk(clubId);
@@ -89,6 +94,13 @@ 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);
if (incomingInvoiceNextNumber !== undefined) updates.incomingInvoiceNextNumber = this.normalizeInvoiceNextNumber(incomingInvoiceNextNumber);
return await club.update(updates);
}
@@ -123,6 +135,45 @@ 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;
}
normalizeInvoiceNextNumber(value) {
const numeric = Number.parseInt(value, 10);
return Number.isInteger(numeric) && numeric > 0 ? numeric : 1;
}
async approveUserClubAccess(userToken, clubId, toApproveUserId) {
await checkAccess(userToken, clubId);
const toApproveUserClub = await UserClub.findOne({

View File

@@ -0,0 +1,287 @@
import { ClubPaymentClaim, ClubRequest, Member } from '../models/index.js';
function isMissingTableError(error, tableName) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& String(error?.original?.sqlMessage || '').includes(tableName);
}
function getMonthKey(dateLike) {
const date = new Date(dateLike);
if (Number.isNaN(date.getTime())) return null;
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
function buildLastMonthsTemplate(monthCount = 12) {
const months = [];
const cursor = new Date();
cursor.setDate(1);
for (let i = monthCount - 1; i >= 0; i -= 1) {
const monthDate = new Date(cursor.getFullYear(), cursor.getMonth() - i, 1);
months.push({
key: getMonthKey(monthDate),
label: `${String(monthDate.getMonth() + 1).padStart(2, '0')}.${monthDate.getFullYear()}`,
newMembers: 0,
memberCountSnapshot: 0,
claimOpenAmountCents: 0,
claimPaidAmountCents: 0,
claimOpenCount: 0,
claimPaidCount: 0,
sponsorRequests: 0,
sponsorOpenRequests: 0,
sponsorConvertedRequests: 0,
});
}
return months;
}
function getAgeFromBirthDate(birthDate) {
if (!birthDate || typeof birthDate !== 'string') return null;
const value = birthDate.trim();
if (!value) return null;
let year;
let month;
let day;
const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
const german = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
if (iso) {
year = Number(iso[1]);
month = Number(iso[2]) - 1;
day = Number(iso[3]);
} else if (german) {
year = Number(german[3]);
month = Number(german[2]) - 1;
day = Number(german[1]);
} else {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return null;
year = parsed.getFullYear();
month = parsed.getMonth();
day = parsed.getDate();
}
const birth = new Date(year, month, day);
if (Number.isNaN(birth.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
age -= 1;
}
return age >= 0 ? age : null;
}
function getAgeBucket(age) {
if (age == null) return null;
if (age < 12) return 'under_12';
if (age < 18) return '12_17';
if (age < 27) return '18_26';
if (age < 41) return '27_40';
if (age < 61) return '41_60';
return '61_plus';
}
const AGE_BUCKET_LABELS = {
under_12: 'Unter 12',
'12_17': '12 bis 17',
'18_26': '18 bis 26',
'27_40': '27 bis 40',
'41_60': '41 bis 60',
'61_plus': '61+',
};
class ClubStatisticsService {
async getClubStatistics(clubIdRaw) {
const clubId = Number.parseInt(clubIdRaw, 10);
if (!Number.isFinite(clubId)) {
const error = new Error('Ungültige clubId');
error.status = 400;
throw error;
}
const months = buildLastMonthsTemplate(12);
const monthMap = new Map(months.map((entry) => [entry.key, entry]));
const currentYear = new Date().getFullYear();
const members = await Member.findAll({
where: { clubId },
order: [['createdAt', 'ASC']],
});
let paymentClaims = [];
try {
paymentClaims = await ClubPaymentClaim.findAll({
where: { clubId },
order: [['dueOn', 'ASC']],
});
} catch (error) {
if (!isMissingTableError(error, 'club_payment_claims')) {
throw error;
}
}
let sponsorRequests = [];
try {
sponsorRequests = await ClubRequest.findAll({
where: {
clubId,
requestType: 'sponsoring',
},
order: [['receivedAt', 'ASC']],
});
} catch (error) {
if (!isMissingTableError(error, 'club_requests')) {
throw error;
}
}
const memberOverview = {
activeMembers: members.filter((member) => member.active && !member.testMembership).length,
inactiveMembers: members.filter((member) => !member.active).length,
testMembers: members.filter((member) => member.testMembership).length,
createdThisYear: members.filter((member) => new Date(member.createdAt).getFullYear() === currentYear).length,
};
const cumulativeMembers = [];
let runningTotal = 0;
for (const month of months) {
const monthDate = new Date(`${month.key}-01T00:00:00`);
const monthEnd = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 0, 23, 59, 59, 999);
const newMembers = members.filter((member) => {
const createdAt = new Date(member.createdAt);
return createdAt >= monthDate && createdAt <= monthEnd;
}).length;
month.newMembers = newMembers;
runningTotal += newMembers;
month.memberCountSnapshot = runningTotal;
cumulativeMembers.push(month);
}
const ageCounts = {
under_12: 0,
'12_17': 0,
'18_26': 0,
'27_40': 0,
'41_60': 0,
'61_plus': 0,
};
let missingBirthdates = 0;
let knownBirthdates = 0;
for (const member of members.filter((entry) => entry.active)) {
const age = getAgeFromBirthDate(member.birthDate);
const bucket = getAgeBucket(age);
if (!bucket) {
missingBirthdates += 1;
continue;
}
knownBirthdates += 1;
ageCounts[bucket] += 1;
}
const paymentTotals = {
openCount: 0,
openAmountCents: 0,
paidCount: 0,
paidAmountCents: 0,
overdueCount: 0,
overdueAmountCents: 0,
};
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 += paidAmount || amount;
if (monthEntry) {
monthEntry.claimPaidCount += 1;
monthEntry.claimPaidAmountCents += paidAmount || amount;
}
} else if (['open', 'partially_paid'].includes(claim.status)) {
paymentTotals.openCount += 1;
paymentTotals.openAmountCents += remainingAmount;
if (dueDate && dueDate < today) {
paymentTotals.overdueCount += 1;
paymentTotals.overdueAmountCents += remainingAmount;
}
if (monthEntry) {
monthEntry.claimOpenCount += 1;
monthEntry.claimOpenAmountCents += remainingAmount;
}
}
}
const sponsorTotals = {
totalRequests: sponsorRequests.length,
openRequests: sponsorRequests.filter((entry) => ['open', 'in_progress', 'waiting'].includes(entry.status)).length,
convertedRequests: sponsorRequests.filter((entry) => entry.status === 'converted').length,
archivedRequests: sponsorRequests.filter((entry) => ['archived', 'rejected'].includes(entry.status)).length,
};
for (const request of sponsorRequests) {
const monthKey = getMonthKey(request.receivedAt || request.createdAt);
const monthEntry = monthKey ? monthMap.get(monthKey) : null;
if (!monthEntry) continue;
monthEntry.sponsorRequests += 1;
if (['open', 'in_progress', 'waiting'].includes(request.status)) {
monthEntry.sponsorOpenRequests += 1;
}
if (request.status === 'converted') {
monthEntry.sponsorConvertedRequests += 1;
}
}
return {
overview: memberOverview,
memberDevelopment: {
monthly: cumulativeMembers.map((entry) => ({
key: entry.key,
label: entry.label,
newMembers: entry.newMembers,
memberCountSnapshot: entry.memberCountSnapshot,
})),
},
ageStructure: {
knownBirthdates,
missingBirthdates,
buckets: Object.entries(ageCounts).map(([key, count]) => ({
key,
label: AGE_BUCKET_LABELS[key] || key,
count,
})),
},
contributionDevelopment: {
totals: paymentTotals,
monthly: months.map((entry) => ({
key: entry.key,
label: entry.label,
openAmountCents: entry.claimOpenAmountCents,
paidAmountCents: entry.claimPaidAmountCents,
openCount: entry.claimOpenCount,
paidCount: entry.claimPaidCount,
})),
},
sponsorDevelopment: {
totals: sponsorTotals,
monthly: months.map((entry) => ({
key: entry.key,
label: entry.label,
totalRequests: entry.sponsorRequests,
openRequests: entry.sponsorOpenRequests,
convertedRequests: entry.sponsorConvertedRequests,
})),
},
};
}
}
export default new ClubStatisticsService();

View File

@@ -0,0 +1,826 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import {
CalendarEvent,
ClubCommunicationRecipient,
ClubCommunicationThread,
ClubDocument,
ClubInvoiceParty,
ClubInvoice,
ClubPaymentClaim,
ClubRequest,
ClubSepaMandate,
ClubTask,
ClubTaskSuppression,
Member,
} from '../models/index.js';
import { CLUB_TASK_DEFINITIONS, CLUB_WORKFLOW_SOURCES, getClubTaskDefinitionMap } from './clubTaskDefinitions.js';
import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js';
const definitionMap = getClubTaskDefinitionMap();
async function loadAvailableTables() {
const tables = await sequelize.getQueryInterface().showAllTables();
return new Set(
tables
.map((table) => (typeof table === 'string' ? table : Object.values(table || {})[0]))
.filter(Boolean)
.map((table) => String(table).toLowerCase())
);
}
function hasTable(availableTables, tableName) {
return availableTables.has(String(tableName).toLowerCase());
}
function activeTask(task) {
return !['done', 'cancelled', 'archived'].includes(task.status);
}
function buildAutomationKey(taskType, entityType, entityId, suffix = '') {
return [taskType, entityType, entityId, suffix].filter(Boolean).join(':');
}
function todayStart() {
const date = new Date();
date.setHours(0, 0, 0, 0);
return date;
}
function addDays(baseDate, days) {
const date = new Date(baseDate);
date.setDate(date.getDate() + days);
return date;
}
function daysUntil(targetDate, today) {
return Math.floor((targetDate.getTime() - today.getTime()) / 86400000);
}
function derivePriority(days) {
if (days < 0) return 'urgent';
if (days <= 2) return 'high';
if (days <= 7) return 'normal';
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';
}
function wrapSuggestion(suggestion) {
return {
...suggestion,
definition: definitionMap[suggestion.taskType] || null,
};
}
function suggestionToken(parts = []) {
return parts
.map((part) => (part == null ? '' : String(part).trim()))
.join('|');
}
function hasExistingSourceTask(tasks, sourceEntityType, sourceEntityId, automationSource) {
return tasks.some((task) =>
task.relatedEntityType === sourceEntityType &&
Number(task.relatedEntityId) === Number(sourceEntityId) &&
task.automationSource === automationSource
);
}
function requestSuggestionFor(request, today) {
const requestDate = new Date(request.receivedAt || today);
const name = personName(request);
if (request.requestType === 'trial_training') {
return {
taskType: 'request_schedule_trial_training',
title: `Probetraining für ${name} organisieren`,
description: 'Anfrage prüfen, Trainingsgruppe auswählen und einen passenden Termin abstimmen.',
priority: 'high',
dueAt: addDays(requestDate, 2),
};
}
if (request.requestType === 'membership') {
return {
taskType: 'request_membership_review',
title: `Mitgliedsanfrage von ${name} prüfen`,
description: 'Unterlagen, Rückfragen und Aufnahmeentscheidung im Vereinskontext bearbeiten.',
priority: 'high',
dueAt: addDays(requestDate, 3),
};
}
if (request.requestType === 'sponsoring') {
return {
taskType: 'request_sponsoring_reply',
title: `Sponsoringanfrage von ${name} nachfassen`,
description: 'Sponsoringanfrage bewerten und nächsten Vereinskontakt vorbereiten.',
priority: 'normal',
dueAt: addDays(requestDate, 4),
};
}
return {
taskType: 'request_contact_reply',
title: `Kontaktanfrage von ${name} beantworten`,
description: 'Rückmeldung an die anfragende Person geben und Zuständigkeit festlegen.',
priority: 'normal',
dueAt: addDays(requestDate, 3),
};
}
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 availableTables = await loadAvailableTables();
const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn();
const [currentTasks, requests, members, mandates, paymentClaims, invoices, parties, documents, communicationRecipients, events, suppressions] = await Promise.all([
ClubTask.findAll({
where: {
clubId,
automationKey: { [Op.ne]: null },
},
}),
hasTable(availableTables, 'club_requests')
? ClubRequest.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'in_progress', 'waiting'] },
},
order: [['receivedAt', 'ASC']],
})
: Promise.resolve([]),
Member.findAll({
where: {
clubId,
active: true,
},
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
}),
hasTable(availableTables, 'club_sepa_mandates')
? ClubSepaMandate.findAll({
where: {
clubId,
status: 'active',
revokedAt: null,
memberId: { [Op.ne]: null },
},
attributes: ['memberId'],
})
: Promise.resolve([]),
hasPaidAmountCentsColumn && hasTable(availableTables, 'club_payment_claims')
? ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
})
: Promise.resolve([]),
hasTable(availableTables, 'club_invoices')
? ClubInvoice.findAll({
where: {
clubId,
status: { [Op.in]: ['issued', 'partially_paid'] },
archivedAt: null,
dueOn: { [Op.ne]: null },
},
order: [['dueOn', 'ASC']],
})
: Promise.resolve([]),
hasTable(availableTables, 'club_invoice_parties')
? ClubInvoiceParty.findAll({
where: {
clubId,
partyType: 'sponsor',
},
order: [['status', 'ASC'], ['validTo', 'ASC'], ['name', 'ASC']],
})
: Promise.resolve([]),
hasTable(availableTables, 'club_documents')
? ClubDocument.findAll({
where: {
clubId,
documentType: { [Op.in]: ['satzung', 'protokoll', 'nachweis'] },
status: { [Op.in]: ['active', 'draft'] },
},
order: [['updatedAt', 'DESC']],
})
: Promise.resolve([]),
hasTable(availableTables, 'club_communication_recipients') && hasTable(availableTables, 'club_communication_threads')
? ClubCommunicationRecipient.findAll({
where: {
clubId,
deliveryStatus: 'failed',
retryable: true,
},
include: [
{ model: ClubCommunicationThread, as: 'thread', required: false },
],
order: [['updatedAt', 'DESC']],
})
: Promise.resolve([]),
hasTable(availableTables, 'calendar_events')
? CalendarEvent.findAll({
where: {
clubId,
endDate: { [Op.gte]: today.toISOString().slice(0, 10) },
},
order: [['startDate', 'ASC']],
limit: 20,
})
: Promise.resolve([]),
hasTable(availableTables, 'club_task_suppressions')
? ClubTaskSuppression.findAll({
where: { clubId },
attributes: ['automationKey', 'suppressionToken'],
})
: Promise.resolve([]),
]);
const existingKeys = new Set(currentTasks.filter(activeTask).map((task) => task.automationKey).filter(Boolean));
const memberIdsWithMandate = new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
const suppressionMap = new Map(
suppressions
.filter((entry) => entry?.automationKey && entry?.suppressionToken)
.map((entry) => [String(entry.automationKey), String(entry.suppressionToken)])
);
const suggestions = [];
const pushSuggestion = (suggestion) => {
const key = String(suggestion.automationKey || '');
const token = String(suggestion.suppressionToken || '');
if (key && token && suppressionMap.get(key) === token) {
return;
}
suggestions.push(wrapSuggestion(suggestion));
};
for (const request of requests) {
const requestSuggestion = requestSuggestionFor(request, today);
if (hasExistingSourceTask(currentTasks, 'club_request', request.id, 'club_requests')) continue;
const key = buildAutomationKey(requestSuggestion.taskType, 'club_request', request.id);
if (existingKeys.has(key)) continue;
pushSuggestion({
...requestSuggestion,
automationSource: 'club_requests',
automationKey: key,
suppressionToken: suggestionToken([
request.updatedAt,
request.status,
request.workflowStage,
request.convertedMemberId
]),
sourceEntityType: 'club_request',
sourceEntityId: request.id,
sourceSnapshot: {
requestType: request.requestType,
status: request.status,
subject: request.subject || null,
person: personName(request),
convertedMemberId: request.convertedMemberId || null,
},
});
}
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()) {
const key = buildAutomationKey('member_missing_email', 'member', member.id, 'email');
if (!existingKeys.has(key)) {
pushSuggestion({
taskType: 'member_missing_email',
title: `E-Mail für ${name} ergänzen`,
description: 'Im Mitgliedsdatensatz fehlt eine E-Mail-Adresse.',
priority: 'normal',
dueAt: addDays(today, 7),
automationSource: 'members',
automationKey: key,
suppressionToken: suggestionToken([member.updatedAt, 'email']),
sourceEntityType: 'member',
sourceEntityId: member.id,
sourceSnapshot: { memberName: name, missingField: 'email' },
});
}
}
if (!String(member.birthDate || '').trim()) {
const key = buildAutomationKey('member_missing_birthdate', 'member', member.id, 'birthdate');
if (!existingKeys.has(key)) {
pushSuggestion({
taskType: 'member_missing_birthdate',
title: `Geburtsdatum für ${name} ergänzen`,
description: 'Im Mitgliedsdatensatz fehlt das Geburtsdatum.',
priority: 'normal',
dueAt: addDays(today, 7),
automationSource: 'members',
automationKey: key,
suppressionToken: suggestionToken([member.updatedAt, 'birthdate']),
sourceEntityType: 'member',
sourceEntityId: member.id,
sourceSnapshot: { memberName: name, missingField: 'birthdate' },
});
}
}
if (!memberIdsWithMandate.has(Number(member.id))) {
const key = buildAutomationKey('member_missing_sepa_mandate', 'member', member.id);
if (!existingKeys.has(key)) {
pushSuggestion({
taskType: 'member_missing_sepa_mandate',
title: `SEPA-Mandat für ${name} einholen`,
description: 'Für dieses aktive Mitglied liegt noch kein aktives SEPA-Mandat vor.',
priority: 'high',
dueAt: addDays(today, 5),
automationSource: 'club_sepa_mandates',
automationKey: key,
suppressionToken: suggestionToken([member.updatedAt, 'sepa-mandate-missing']),
sourceEntityType: 'member',
sourceEntityId: member.id,
sourceSnapshot: { memberName: name },
});
}
}
}
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
? 'payment_claim_overdue'
: 'payment_claim_due_soon';
const key = buildAutomationKey(claimTaskType, 'club_payment_claim', claim.id);
if (existingKeys.has(key)) continue;
pushSuggestion({
taskType: claimTaskType,
title:
claimTaskType === 'payment_claim_reminder'
? `${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 Restforderung über ${(remainingAmountCents / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} mit ${formatReminderStage(claim.reminderLevel)} prüfen.`
: claimTaskType === 'payment_claim_overdue'
? `Ü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',
automationKey: key,
suppressionToken: suggestionToken([claim.updatedAt, claim.status, claim.reminderLevel, claim.dueOn]),
sourceEntityType: 'club_payment_claim',
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 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;
pushSuggestion({
taskType: eventTaskType,
title:
eventTaskType === 'calendar_event_deadline_check'
? `Letzte Prüfung für Termin: ${event.title}`
: `Termin vorbereiten: ${event.title}`,
description:
eventTaskType === 'calendar_event_deadline_check'
? 'Kurz vor dem Termin noch einmal Kommunikation, Teilnehmerstand und letzte Freigaben prüfen.'
: 'Termin organisatorisch prüfen, Verantwortliche abstimmen und offene Punkte schließen.',
priority: derivePriority(daysUntil(dueDate, today)),
dueAt: dueDate,
automationSource: 'calendar_events',
automationKey: key,
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,
},
});
}
suggestions.sort((left, right) => {
const leftDue = left.dueAt ? new Date(left.dueAt).getTime() : Number.MAX_SAFE_INTEGER;
const rightDue = right.dueAt ? new Date(right.dueAt).getTime() : Number.MAX_SAFE_INTEGER;
return leftDue - rightDue;
});
return {
definitions: CLUB_TASK_DEFINITIONS,
workflowSources: CLUB_WORKFLOW_SOURCES,
suggestions,
};
}
async materializeSuggestions(clubId, userId, automationKeys = []) {
const overview = await this.buildAutomationOverview(clubId);
const matches = overview.suggestions.filter((suggestion) => automationKeys.includes(suggestion.automationKey));
const tasks = [];
for (const suggestion of matches) {
const task = await ClubTask.create({
clubId,
title: suggestion.title,
taskType: suggestion.taskType,
description: suggestion.description,
status: 'open',
priority: suggestion.priority,
dueAt: suggestion.dueAt,
createdByUserId: userId || null,
automationSource: suggestion.automationSource,
automationKey: suggestion.automationKey,
relatedEntityType: suggestion.sourceEntityType,
relatedEntityId: suggestion.sourceEntityId,
sourceSnapshot: suggestion.sourceSnapshot,
});
tasks.push(task);
}
return tasks;
}
buildFollowUpSuggestionFromTask(task, nextTaskType) {
const definition = definitionMap[nextTaskType];
if (!definition) {
return null;
}
const snapshot = task.sourceSnapshot || {};
const person = snapshot.person || snapshot.memberName || 'Unbekannt';
const sourceDate = task.dueAt ? new Date(task.dueAt) : todayStart();
switch (nextTaskType) {
case 'request_trial_training_follow_up':
return wrapSuggestion({
taskType: nextTaskType,
title: `Rückmeldung zu Probetraining von ${person} einholen`,
description: 'Nach dem vereinbarten Probetraining Trainerfeedback und Rückmeldung des Interessenten einsammeln.',
priority: 'high',
dueAt: addDays(sourceDate, 2),
automationSource: task.automationSource,
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
sourceEntityType: task.relatedEntityType,
sourceEntityId: task.relatedEntityId,
sourceSnapshot: snapshot,
});
case 'membership_prepare_admission':
return wrapSuggestion({
taskType: nextTaskType,
title: `Aufnahme für ${person} vorbereiten`,
description: 'Aufnahmeentscheidung vorbereiten, fehlende Freigaben klären und Übernahme in den Verein anstoßen.',
priority: 'high',
dueAt: addDays(sourceDate, 2),
automationSource: task.automationSource,
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
sourceEntityType: task.relatedEntityType,
sourceEntityId: task.relatedEntityId,
sourceSnapshot: snapshot,
});
case 'membership_create_member_record':
return wrapSuggestion({
taskType: nextTaskType,
title: `Mitgliedsdatensatz für ${person} anlegen`,
description: 'Mitglied im System anlegen, Stammdaten prüfen und Vereinsstatus sauber setzen.',
priority: 'high',
dueAt: addDays(sourceDate, 1),
automationSource: task.automationSource,
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
sourceEntityType: task.relatedEntityType,
sourceEntityId: task.relatedEntityId,
sourceSnapshot: snapshot,
});
case 'membership_collect_sepa_mandate':
return wrapSuggestion({
taskType: nextTaskType,
title: `SEPA-Mandat für ${person} organisieren`,
description: 'Für das neue Mitglied das SEPA-Mandat einholen oder auf Vollständigkeit prüfen.',
priority: 'high',
dueAt: addDays(sourceDate, 3),
automationSource: task.automationSource,
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
sourceEntityType: task.relatedEntityType,
sourceEntityId: task.relatedEntityId,
sourceSnapshot: snapshot,
});
case 'membership_assign_fee':
return wrapSuggestion({
taskType: nextTaskType,
title: `Beitragszuordnung für ${person} prüfen`,
description: 'Beitragssatz, Ermäßigung oder Familienlogik für das neue Mitglied verbindlich festlegen.',
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 'calendar_event_deadline_check':
return wrapSuggestion({
taskType: nextTaskType,
title: `Letzte Prüfung für Termin: ${snapshot.title || task.title}`,
description: 'Kurz vor dem Termin noch einmal Kommunikation, Teilnehmerstand und letzte Freigaben prüfen.',
priority: 'high',
dueAt: addDays(sourceDate, 1),
automationSource: task.automationSource,
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
sourceEntityType: task.relatedEntityType,
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;
}
}
async materializeWorkflowFollowUps(task, userId) {
const definition = definitionMap[task.taskType];
const nextTaskTypes = Array.isArray(definition?.nextTaskTypes) ? definition.nextTaskTypes : [];
if (nextTaskTypes.length === 0) {
return [];
}
const createdTasks = [];
for (const nextTaskType of nextTaskTypes) {
const followUp = this.buildFollowUpSuggestionFromTask(task, nextTaskType);
if (!followUp) continue;
const existingTask = await ClubTask.findOne({
where: {
clubId: task.clubId,
automationKey: followUp.automationKey,
},
});
if (existingTask) continue;
const createdTask = await ClubTask.create({
clubId: task.clubId,
title: followUp.title,
taskType: followUp.taskType,
description: followUp.description,
status: 'open',
priority: followUp.priority,
dueAt: followUp.dueAt,
createdByUserId: userId || null,
automationSource: followUp.automationSource,
automationKey: followUp.automationKey,
relatedEntityType: followUp.sourceEntityType,
relatedEntityId: followUp.sourceEntityId,
sourceSnapshot: followUp.sourceSnapshot,
});
createdTasks.push(createdTask);
}
return createdTasks;
}
async dismissSuggestion(clubId, userId, suggestionPayload = {}) {
const automationKey = String(suggestionPayload.automationKey || '').trim();
const suppressionTokenValue = String(suggestionPayload.suppressionToken || '').trim();
if (!automationKey || !suppressionTokenValue) {
const error = new Error('Automatik-Schlüssel und Unterdrückungs-Token sind erforderlich.');
error.statusCode = 400;
throw error;
}
const [row] = await ClubTaskSuppression.findOrCreate({
where: { clubId, automationKey },
defaults: {
clubId,
automationKey,
suppressionToken: suppressionTokenValue,
dismissedByUserId: userId || null,
},
});
if (row.suppressionToken !== suppressionTokenValue || Number(row.dismissedByUserId || 0) !== Number(userId || 0)) {
row.suppressionToken = suppressionTokenValue;
row.dismissedByUserId = userId || null;
await row.save();
}
return row;
}
}
export default new ClubTaskAutomationService();

View File

@@ -0,0 +1,352 @@
export const CLUB_TASK_DEFINITIONS = [
{
key: 'request_contact_reply',
label: 'Kontaktanfrage beantworten',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Anfragebearbeitung',
trigger: 'Kontaktanfrage ist offen oder wartet auf Rückmeldung.',
description: 'Antwort an eine allgemeine Kontaktanfrage vorbereiten und den nächsten Vereinskontakt sichern.',
suggestedAction: 'Antwort verfassen und zuständige Person festlegen.',
nextTaskTypes: [],
},
{
key: 'request_schedule_trial_training',
label: 'Probetraining organisieren',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Probetraining',
trigger: 'Probetraining wurde angefragt und noch nicht konkret terminiert.',
description: 'Probetraining terminieren, Ansprechpartner bestimmen und Rückmeldung an den Interessenten senden.',
suggestedAction: 'Termin abstimmen, Trainingsgruppe auswählen, Einladung senden.',
nextTaskTypes: ['request_trial_training_follow_up'],
},
{
key: 'request_trial_training_follow_up',
label: 'Nach Probetraining Rückmeldung einholen',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Probetraining',
trigger: 'Probetraining-Anfrage ist in Bearbeitung oder wartet auf Entscheidung.',
description: 'Nach dem Probetraining Rückmeldung einholen und über Mitgliedsantrag oder Absage entscheiden.',
suggestedAction: 'Trainerfeedback holen und nächsten Schritt festlegen.',
nextTaskTypes: [],
},
{
key: 'request_membership_review',
label: 'Mitgliedsanfrage prüfen',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Mitgliedschaft',
trigger: 'Mitgliedsanfrage ist offen oder unvollständig.',
description: 'Mitgliedsantrag prüfen, fehlende Unterlagen nachfordern und Aufnahme vorbereiten.',
suggestedAction: 'Unterlagen prüfen und Aufnahmeprozess anstoßen.',
nextTaskTypes: ['membership_prepare_admission'],
},
{
key: 'membership_prepare_admission',
label: 'Aufnahme vorbereiten',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Mitgliedschaft',
trigger: 'Mitgliedsanfrage wurde fachlich geprüft und soll in die Aufnahme überführt werden.',
description: 'Aufnahmeentscheidung vorbereiten, Freigaben einholen und die formale Übernahme in den Verein anstoßen.',
suggestedAction: 'Aufnahmestatus klären und Übergang in die Mitgliedsdaten vorbereiten.',
nextTaskTypes: ['membership_create_member_record'],
},
{
key: 'membership_create_member_record',
label: 'Mitgliedsdatensatz anlegen',
source: 'club_requests',
category: 'Mitglieder',
workflow: 'Mitgliedschaft',
trigger: 'Aufnahme ist entschieden und der Datensatz muss im Verein sauber angelegt oder geprüft werden.',
description: 'Mitglied im System anlegen, Stammdaten prüfen und den Vereinskontext vollständig herstellen.',
suggestedAction: 'Mitgliedsnummer, Status und Basisdaten vervollständigen.',
nextTaskTypes: ['membership_collect_sepa_mandate'],
},
{
key: 'membership_collect_sepa_mandate',
label: 'SEPA für neues Mitglied einholen',
source: 'club_requests',
category: 'Finanzen',
workflow: 'Mitgliedschaft',
trigger: 'Neues Mitglied ist angelegt, aber der Beitragseinzug muss noch vorbereitet werden.',
description: 'SEPA-Mandat für das neu aufgenommene Mitglied organisieren und den Beitragseinzug vorbereiten.',
suggestedAction: 'Mandatsformular anfordern, prüfen oder zur Unterschrift versenden.',
nextTaskTypes: ['membership_assign_fee'],
},
{
key: 'membership_assign_fee',
label: 'Beitragszuordnung prüfen',
source: 'club_requests',
category: 'Finanzen',
workflow: 'Mitgliedschaft',
trigger: 'Mitglied ist angelegt und finanzseitig in die richtige Beitragslogik einzuordnen.',
description: 'Passenden Beitragssatz, Ermäßigung oder Familienbeitrag für das neue Mitglied prüfen.',
suggestedAction: 'Beitragsregel festlegen und Zuordnung kontrollieren.',
nextTaskTypes: [],
},
{
key: 'request_sponsoring_reply',
label: 'Sponsoringanfrage nachfassen',
source: 'club_requests',
category: 'Anfragen',
workflow: 'Sponsoring',
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: [],
},
{
key: 'member_missing_email',
label: 'Mitgliedsdaten ergänzen: E-Mail',
source: 'members',
category: 'Mitglieder',
workflow: 'Datenqualität',
trigger: 'Aktives Mitglied ohne E-Mail-Adresse.',
description: 'Entsteht, wenn bei einem aktiven Mitglied keine E-Mail-Adresse gepflegt ist.',
suggestedAction: 'Kontakt aufnehmen und E-Mail nachpflegen.',
nextTaskTypes: [],
},
{
key: 'member_missing_birthdate',
label: 'Mitgliedsdaten ergänzen: Geburtsdatum',
source: 'members',
category: 'Mitglieder',
workflow: 'Datenqualität',
trigger: 'Aktives Mitglied ohne Geburtsdatum.',
description: 'Entsteht, wenn bei einem aktiven Mitglied kein Geburtsdatum gepflegt ist.',
suggestedAction: 'Geburtsdatum verifizieren und im Datensatz ergänzen.',
nextTaskTypes: [],
},
{
key: 'member_missing_sepa_mandate',
label: 'SEPA-Mandat einholen',
source: 'club_sepa_mandates',
category: 'Finanzen',
workflow: 'Beitragseinzug',
trigger: 'Aktives Mitglied ohne aktives SEPA-Mandat.',
description: 'Entsteht, wenn ein aktives Mitglied noch kein aktives SEPA-Mandat hat.',
suggestedAction: 'Mandatsformular anfordern oder zur Unterschrift versenden.',
nextTaskTypes: [],
},
{
key: 'payment_claim_due_soon',
label: 'Fällige Zahlung vorbereiten',
source: 'club_payment_claims',
category: 'Finanzen',
workflow: 'Zahlungseingänge',
trigger: 'Forderung ist bald fällig, aber noch nicht überfällig.',
description: 'Vor Fälligkeit prüfen, ob der Beitragseinzug oder die Zahlungserinnerung vorbereitet ist.',
suggestedAction: 'Einzug oder Zahlungserinnerung vorbereiten.',
nextTaskTypes: [],
},
{
key: 'payment_claim_overdue',
label: 'Überfällige Zahlung nachfassen',
source: 'club_payment_claims',
category: 'Finanzen',
workflow: 'Zahlungseingänge',
trigger: 'Forderung ist überfällig.',
description: 'Überfällige Beiträge priorisiert nachverfolgen und den nächsten Mahn- oder Kontakt-Schritt auslösen.',
suggestedAction: 'Mitglied kontaktieren oder Mahnstufe erhöhen.',
nextTaskTypes: [],
},
{
key: 'payment_claim_reminder',
label: 'Mahnstufe prüfen',
source: 'club_payment_claims',
category: 'Finanzen',
workflow: 'Zahlungseingänge',
trigger: 'Offene Forderung hat bereits eine Mahnstufe.',
description: 'Bestehende Mahnfälle prüfen und entscheiden, ob eine weitere Eskalation oder Klärung nötig ist.',
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',
source: 'calendar_events',
category: 'Termine',
workflow: 'Terminorganisation',
trigger: 'Termin rückt näher.',
description: 'Entsteht vor anstehenden Vereins- und Kalenderterminen als organisatorische Wiedervorlage.',
suggestedAction: 'Verantwortliche, Räume, Kommunikation und offene Punkte prüfen.',
nextTaskTypes: ['calendar_event_deadline_check'],
},
{
key: 'calendar_event_deadline_check',
label: 'Terminfrist prüfen',
source: 'calendar_events',
category: 'Termine',
workflow: 'Terminorganisation',
trigger: 'Termin oder Frist steht kurzfristig bevor.',
description: 'Kurzfristige Frist oder Veranstaltung vor Durchführung auf Vollständigkeit und Kommunikation prüfen.',
suggestedAction: 'Teilnehmerstand, Erinnerungen und letzte Freigaben prüfen.',
nextTaskTypes: [],
},
];
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;
return accumulator;
}, {});
}

View File

@@ -1,8 +1,10 @@
import ClubTeam from '../models/ClubTeam.js';
import ClubTeamMember from '../models/ClubTeamMember.js';
import League from '../models/League.js';
import Member from '../models/Member.js';
import Season from '../models/Season.js';
import {
ClubTeam,
ClubTeamMember,
League,
Member,
Season
} from '../models/index.js';
import SeasonService from './seasonService.js';
import { devLog } from '../utils/logger.js';
@@ -190,16 +192,38 @@ class ClubTeamService {
static async getTeamLineup(clubTeamId, lineupHalf = 'first_half') {
try {
return await ClubTeamMember.findAll({
const lineupEntries = await ClubTeamMember.findAll({
where: { clubTeamId, lineupHalf },
include: [
{
model: Member,
as: 'member'
}
],
order: [['position', 'ASC']]
});
if (lineupEntries.length === 0) {
return [];
}
const memberIds = [...new Set(
lineupEntries
.map((entry) => Number(entry.memberId))
.filter((memberId) => Number.isInteger(memberId) && memberId > 0)
)];
const members = memberIds.length > 0
? await Member.findAll({
where: { id: memberIds }
})
: [];
const memberById = new Map(
members.map((member) => [Number(member.id), member])
);
return lineupEntries.map((entry) => {
const plainEntry = entry.get({ plain: true });
return {
...plainEntry,
member: memberById.get(Number(entry.memberId)) || null
};
});
} catch (error) {
if (this.isMissingTeamLineupTable(error)) {
return [];

View File

@@ -0,0 +1,69 @@
import { ClubRequest } from '../models/index.js';
function completedRequestStateForTaskType(taskType) {
switch (taskType) {
case 'request_contact_reply':
return { status: 'waiting', workflowStage: 'contact_replied' };
case 'request_schedule_trial_training':
return { status: 'in_progress', workflowStage: 'trial_training_scheduled' };
case 'request_trial_training_follow_up':
return { status: 'waiting', workflowStage: 'trial_training_feedback_recorded' };
case 'request_membership_review':
return { status: 'in_progress', workflowStage: 'membership_reviewed' };
case 'membership_prepare_admission':
return { status: 'in_progress', workflowStage: 'admission_prepared' };
case 'membership_create_member_record':
return { status: 'in_progress', workflowStage: 'member_record_created' };
case 'membership_collect_sepa_mandate':
return { status: 'in_progress', workflowStage: 'sepa_pending' };
case 'membership_assign_fee':
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;
}
}
class ClubWorkflowSourceService {
async syncSourceStateForCompletedTask(task) {
if (task.relatedEntityType !== 'club_request' || !task.relatedEntityId) {
return null;
}
const requestState = completedRequestStateForTaskType(task.taskType);
if (!requestState) {
return null;
}
const request = await ClubRequest.findOne({
where: {
id: task.relatedEntityId,
clubId: task.clubId,
},
});
if (!request) {
return null;
}
await request.update({
status: requestState.status,
workflowStage: requestState.workflowStage,
closedAt: requestState.closedAt || null,
});
return {
entityType: 'club_request',
entityId: request.id,
status: request.status,
workflowStage: request.workflowStage,
};
}
}
export default new ClubWorkflowSourceService();

View File

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

View File

@@ -5,6 +5,7 @@ import Member from "../models/Member.js";
import MemberImage from "../models/MemberImage.js";
import MemberTtrHistory from "../models/MemberTtrHistory.js";
import MemberPlayInterest from "../models/MemberPlayInterest.js";
import ClubSepaMandate from "../models/ClubSepaMandate.js";
import Participant from "../models/Participant.js";
import DiaryDate from "../models/DiaryDates.js";
import { Op, fn, col } from 'sequelize';
@@ -15,6 +16,132 @@ import sharp from 'sharp';
import { devLog } from '../utils/logger.js';
import { standardizePhoneNumber } from '../utils/phoneUtils.js';
class MemberService {
normalizeSepaMandatePayload(payload = {}) {
const normalizeText = (value, maxLength = null) => {
if (value === null || value === undefined) return null;
const trimmed = String(value).trim();
if (!trimmed) return null;
return maxLength ? trimmed.slice(0, maxLength) : trimmed;
};
const normalizeDate = (value) => {
const normalized = normalizeText(value, 10);
return normalized || null;
};
const status = normalizeText(payload.status, 32) || 'active';
return {
debtorName: normalizeText(payload.debtorName, 255),
iban: normalizeText(payload.iban, 34),
bic: normalizeText(payload.bic, 11),
mandateReference: normalizeText(payload.mandateReference, 80),
signedOn: normalizeDate(payload.signedOn),
validFrom: normalizeDate(payload.validFrom),
status,
historyNote: normalizeText(payload.historyNote),
revokedAt: status === 'revoked'
? (normalizeText(payload.revokedAt) || new Date().toISOString())
: null
};
}
async getMemberSepaMandate(userToken, clubId, memberId) {
await checkAccess(userToken, clubId);
const member = await Member.findOne({ where: { id: memberId, clubId } });
if (!member) {
return {
status: 404,
response: { success: false, error: 'membernotfound' }
};
}
const mandate = await ClubSepaMandate.findOne({
where: { clubId, memberId },
order: [['updatedAt', 'DESC'], ['id', 'DESC']]
});
return {
status: 200,
response: {
success: true,
mandate: mandate ? mandate.toJSON() : null
}
};
}
async saveMemberSepaMandate(userToken, clubId, memberId, payload = {}) {
await checkAccess(userToken, clubId);
const member = await Member.findOne({ where: { id: memberId, clubId } });
if (!member) {
return {
status: 404,
response: { success: false, error: 'membernotfound' }
};
}
const normalizedPayload = this.normalizeSepaMandatePayload(payload);
const hasContent = Boolean(
normalizedPayload.debtorName
|| normalizedPayload.iban
|| normalizedPayload.bic
|| normalizedPayload.mandateReference
|| normalizedPayload.signedOn
|| normalizedPayload.validFrom
|| normalizedPayload.historyNote
);
let mandate = await ClubSepaMandate.findOne({
where: { clubId, memberId },
order: [['updatedAt', 'DESC'], ['id', 'DESC']]
});
if (!mandate && !hasContent) {
return {
status: 200,
response: { success: true, mandate: null }
};
}
if (!mandate) {
if (!normalizedPayload.debtorName || !normalizedPayload.iban || !normalizedPayload.mandateReference) {
return {
status: 400,
response: {
success: false,
code: 'missingrequiredsepafields',
error: 'Bitte Kontoinhaber, IBAN und Mandatsreferenz angeben.'
}
};
}
mandate = await ClubSepaMandate.create({
clubId,
memberId,
...normalizedPayload
});
} else {
mandate.debtorName = normalizedPayload.debtorName;
mandate.iban = normalizedPayload.iban;
mandate.bic = normalizedPayload.bic;
mandate.mandateReference = normalizedPayload.mandateReference;
mandate.signedOn = normalizedPayload.signedOn;
mandate.validFrom = normalizedPayload.validFrom;
mandate.status = normalizedPayload.status;
mandate.historyNote = normalizedPayload.historyNote;
mandate.revokedAt = normalizedPayload.revokedAt;
await mandate.save();
}
await mandate.reload();
return {
status: 200,
response: {
success: true,
mandate: mandate.toJSON()
}
};
}
async getMemberPlayInterests(userToken, clubId, seasonId, lineupHalf) {
await checkAccess(userToken, clubId);
if (!seasonId || !['first_half', 'second_half'].includes(String(lineupHalf || ''))) {
@@ -165,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;
@@ -173,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;
@@ -191,6 +320,7 @@ class MemberService {
member.memberFormHandedOver = !!memberFormHandedOver;
member.adultReleaseApproved = !!adultReleaseApproved;
member.adultReserveApproved = !!adultReserveApproved;
member.contributionGroupCode = normalizedContributionGroupCode;
await member.save();
// Update contacts if provided
@@ -236,6 +366,7 @@ class MemberService {
memberFormHandedOver: !!memberFormHandedOver,
adultReleaseApproved: !!adultReleaseApproved,
adultReserveApproved: !!adultReserveApproved,
contributionGroupCode: normalizedContributionGroupCode,
});
// Create contacts if provided

View File

@@ -1,393 +1,642 @@
import UserClub from '../models/UserClub.js';
import Club from '../models/Club.js';
import User from '../models/User.js';
import ClubRole from '../models/ClubRole.js';
import ClubUserRole from '../models/ClubUserRole.js';
/**
* Permission Service
* Handles all permission-related logic
*/
// Default permissions for each role
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 }, // Can manage other users' permissions
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 }
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 }
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 }
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 }
predefined_activities: { read: false, write: false, delete: false },
},
member: {
cashier: {
diary: { read: false, write: false, delete: false },
members: { 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 }
}
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 },
},
};
const DEFAULT_ROLE_TEMPLATES = [
{ roleKey: 'admin', name: 'Administrator', description: 'Vollzugriff auf alle Funktionen', permissions: ROLE_PERMISSIONS.admin, sortOrder: 10 },
{ 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 },
];
function cloneValue(value) {
return JSON.parse(JSON.stringify(value));
}
function normalizePermissions(value) {
if (!value) return {};
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (_error) {
return {};
}
}
return cloneValue(value);
}
function slugifyRoleKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64) || 'rolle';
}
class PermissionService {
/**
* Get user's permissions for a specific club
*/
async getUserClubPermissions(userId, clubId) {
const userClub = await UserClub.findOne({
where: {
userId,
clubId,
approved: true
}
});
mergePermissions(basePermissions = {}, extraPermissions = {}) {
const merged = cloneValue(basePermissions);
const normalizedExtra = normalizePermissions(extraPermissions);
if (!userClub) {
return null;
}
// If user is owner, they have full admin rights
if (userClub.isOwner) {
return {
role: 'admin',
isOwner: true,
permissions: ROLE_PERMISSIONS.admin
};
}
// Get role from database, fallback to 'member' if null/undefined
const role = userClub.role || 'member';
// Get role-based permissions
const rolePermissions = ROLE_PERMISSIONS[role] || ROLE_PERMISSIONS.member;
// Merge with custom permissions if any
const customPermissions = userClub.permissions || {};
const mergedPermissions = this.mergePermissions(rolePermissions, customPermissions);
return {
role: role,
isOwner: false,
permissions: mergedPermissions
};
}
/**
* Check if user has specific permission
*/
async hasPermission(userId, clubId, resource, action) {
const userPermissions = await this.getUserClubPermissions(userId, clubId);
if (!userPermissions) {
return false;
}
// Owner always has permission
if (userPermissions.isOwner) {
return true;
}
// MyTischtennis settings are accessible to all approved members
if (resource === 'mytischtennis') {
return true;
}
const resourcePermissions = userPermissions.permissions[resource];
if (!resourcePermissions) {
return false;
}
return resourcePermissions[action] === true;
}
/**
* Set user role in club
*/
async setUserRole(userId, clubId, role, updatedByUserId) {
// Check if updater has permission
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Rollen');
}
// Check if target user is owner
const targetUserClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Die Rolle des Club-Erstellers kann nicht geändert werden');
}
// Validate role
if (!ROLE_PERMISSIONS[role]) {
throw new Error('Ungültige Rolle');
}
await targetUserClub.update({ role });
return {
success: true,
message: 'Rolle erfolgreich aktualisiert'
};
}
/**
* Set custom permissions for user
*/
async setCustomPermissions(userId, clubId, customPermissions, updatedByUserId) {
// Check if updater has permission
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Berechtigungen');
}
// Check if target user is owner
const targetUserClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Die Berechtigungen des Club-Erstellers können nicht geändert werden');
}
await targetUserClub.update({ permissions: customPermissions });
return {
success: true,
message: 'Berechtigungen erfolgreich aktualisiert'
};
}
/**
* Set user status (activate/deactivate)
*/
async setUserStatus(userId, clubId, approved, updatedByUserId) {
// Check if updater has permission
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern des Status');
}
// Check if target user is owner
const targetUserClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Der Status des Club-Erstellers kann nicht geändert werden');
}
await targetUserClub.update({ approved });
return {
success: true,
message: approved ? 'Benutzer erfolgreich aktiviert' : 'Benutzer erfolgreich deaktiviert'
};
}
/**
* Get all club members with their permissions
*/
async getClubMembersWithPermissions(clubId, requestingUserId) {
// Check if requester has permission to read permissions
const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read');
if (!canReadPermissions) {
throw new Error('Keine Berechtigung zum Anzeigen von Berechtigungen');
}
const userClubs = await UserClub.findAll({
where: {
clubId
},
include: [{
model: User,
as: 'user',
attributes: ['id', 'email']
}]
});
return userClubs.map(uc => {
// Parse permissions JSON string to object
let parsedPermissions = null;
if (uc.permissions) {
try {
parsedPermissions = typeof uc.permissions === 'string'
? JSON.parse(uc.permissions)
: uc.permissions;
} catch (err) {
console.error('Error parsing permissions JSON:', err);
parsedPermissions = null;
}
}
return {
userId: uc.userId,
user: uc.user,
role: uc.role,
isOwner: uc.isOwner,
approved: uc.approved,
permissions: parsedPermissions,
effectivePermissions: this.getEffectivePermissions(uc)
};
});
}
/**
* Get effective permissions (role + custom)
*/
getEffectivePermissions(userClub) {
if (userClub.isOwner) {
return ROLE_PERMISSIONS.admin;
}
const rolePermissions = ROLE_PERMISSIONS[userClub.role] || ROLE_PERMISSIONS.member;
// Parse permissions JSON string to object
let customPermissions = {};
if (userClub.permissions) {
try {
customPermissions = typeof userClub.permissions === 'string'
? JSON.parse(userClub.permissions)
: userClub.permissions;
} catch (err) {
console.error('Error parsing permissions JSON in getEffectivePermissions:', err);
customPermissions = {};
}
}
return this.mergePermissions(rolePermissions, customPermissions);
}
/**
* Merge role permissions with custom permissions
*/
mergePermissions(rolePermissions, customPermissions) {
const merged = { ...rolePermissions };
for (const resource in customPermissions) {
if (!merged[resource]) {
merged[resource] = {};
}
for (const resource of Object.keys(normalizedExtra)) {
merged[resource] = {
...merged[resource],
...customPermissions[resource]
...(merged[resource] || {}),
...(normalizedExtra[resource] || {}),
};
}
return merged;
}
/**
* Mark user as club owner (used when creating a club)
*/
async setClubOwner(userId, clubId) {
const userClub = await UserClub.findOne({
where: { userId, clubId }
});
if (!userClub) {
throw new Error('UserClub relationship not found');
}
await userClub.update({
isOwner: true,
role: 'admin',
approved: true
});
}
/**
* Get all available roles
*/
getAvailableRoles() {
return [
{ value: 'admin', label: 'Administrator', description: 'Vollzugriff auf alle Funktionen' },
{ value: 'trainer', label: 'Trainer', description: 'Kann Trainingseinheiten, Mitglieder und Teams verwalten' },
{ value: 'team_manager', label: 'Mannschaftsführer', description: 'Kann Teams und Spielpläne verwalten' },
{ value: 'tournament_manager', label: 'Turnierleiter', description: 'Kann Turniere verwalten' },
{ value: 'member', label: 'Mitglied', description: 'Kann nur Trainings-Statistiken ansehen' }
];
}
/**
* Get permission structure for frontend
*/
getPermissionStructure() {
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'] }
predefined_activities: { label: 'Vordefinierte Aktivitäten', actions: ['read', 'write', 'delete'] },
};
}
getAvailableRoles() {
return DEFAULT_ROLE_TEMPLATES.map((role) => ({
value: role.roleKey,
label: role.name,
description: role.description,
isSystemRole: true,
}));
}
isMissingRoleTableError(error) {
return error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_roles|club_user_roles/.test(String(error?.original?.sqlMessage || ''));
}
async ensureDefaultRoles(clubId) {
const createdRoles = [];
for (const template of DEFAULT_ROLE_TEMPLATES) {
const [role, created] = await ClubRole.findOrCreate({
where: { clubId, roleKey: template.roleKey },
defaults: {
clubId,
roleKey: template.roleKey,
name: template.name,
description: template.description,
permissions: template.permissions,
isSystemRole: true,
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;
}
async getRoleAssignments(clubId, userIds = null) {
const where = { clubId };
if (Array.isArray(userIds)) {
where.userId = userIds;
}
return ClubUserRole.findAll({
where,
include: [{
model: ClubRole,
as: 'role',
}],
order: [
['isPrimary', 'DESC'],
[{ model: ClubRole, as: 'role' }, 'sortOrder', 'ASC'],
[{ model: ClubRole, as: 'role' }, 'name', 'ASC'],
],
});
}
buildLegacyPermissionPayload(userClub) {
if (userClub.isOwner) {
return {
role: 'admin',
roles: [{ roleKey: 'admin', name: 'Administrator', isPrimary: true, isSystemRole: true }],
isOwner: true,
isAdmin: true,
permissions: cloneValue(ROLE_PERMISSIONS.admin),
};
}
const primaryRole = userClub.role || 'member';
const effectivePermissions = this.mergePermissions(
ROLE_PERMISSIONS[primaryRole] || ROLE_PERMISSIONS.member,
userClub.permissions
);
return {
role: primaryRole,
roles: [{
roleKey: primaryRole,
name: DEFAULT_ROLE_TEMPLATES.find((role) => role.roleKey === primaryRole)?.name || primaryRole,
isPrimary: true,
isSystemRole: true,
}],
isOwner: false,
isAdmin: primaryRole === 'admin',
permissions: effectivePermissions,
};
}
buildRolePermissionPayload(userClub, assignments) {
if (userClub.isOwner) {
return {
role: 'admin',
roles: [{ roleKey: 'admin', name: 'Administrator', isPrimary: true, isSystemRole: true }],
isOwner: true,
isAdmin: true,
permissions: cloneValue(ROLE_PERMISSIONS.admin),
};
}
const normalizedAssignments = assignments
.filter((assignment) => assignment?.role)
.map((assignment) => ({
id: assignment.role.id,
roleKey: assignment.role.roleKey,
name: assignment.role.name,
description: assignment.role.description,
isSystemRole: Boolean(assignment.role.isSystemRole),
isPrimary: Boolean(assignment.isPrimary),
assignedAt: assignment.createdAt || null,
assignmentUpdatedAt: assignment.updatedAt || null,
roleCreatedAt: assignment.role.createdAt || null,
roleUpdatedAt: assignment.role.updatedAt || null,
permissions: normalizePermissions(assignment.role.permissions),
}));
if (normalizedAssignments.length === 0) {
return this.buildLegacyPermissionPayload(userClub);
}
const primaryRole = normalizedAssignments.find((role) => role.isPrimary) || normalizedAssignments[0];
const rolePermissions = normalizedAssignments.reduce(
(accumulator, role) => this.mergePermissions(accumulator, role.permissions),
{}
);
const effectivePermissions = this.mergePermissions(rolePermissions, userClub.permissions);
return {
role: normalizedAssignments.some((role) => role.roleKey === 'admin') ? 'admin' : primaryRole.roleKey,
roles: normalizedAssignments.map(({ permissions, ...role }) => role),
isOwner: false,
isAdmin: normalizedAssignments.some((role) => role.roleKey === 'admin'),
permissions: effectivePermissions,
};
}
async getUserClubPermissions(userId, clubId) {
const userClub = await UserClub.findOne({
where: { userId, clubId, approved: true },
});
if (!userClub) {
return null;
}
try {
await this.ensureDefaultRoles(clubId);
const assignments = await this.getRoleAssignments(clubId, [userId]);
return this.buildRolePermissionPayload(userClub, assignments);
} catch (error) {
if (this.isMissingRoleTableError(error)) {
return this.buildLegacyPermissionPayload(userClub);
}
throw error;
}
}
async hasPermission(userId, clubId, resource, action) {
const userPermissions = await this.getUserClubPermissions(userId, clubId);
if (!userPermissions) {
return false;
}
if (userPermissions.isOwner) {
return true;
}
if (resource === 'mytischtennis') {
return true;
}
return userPermissions.permissions?.[resource]?.[action] === true;
}
async setUserRole(userId, clubId, roleKey, updatedByUserId) {
await this.ensureDefaultRoles(clubId);
const role = await ClubRole.findOne({ where: { clubId, roleKey } });
if (!role) {
throw new Error('Ungültige Rolle');
}
return this.setUserRoles(userId, clubId, [role.id], updatedByUserId);
}
async setUserRoles(userId, clubId, roleIds, updatedByUserId) {
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Rollen');
}
const targetUserClub = await UserClub.findOne({ where: { userId, clubId } });
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Die Rollen des Club-Erstellers können nicht geändert werden');
}
await this.ensureDefaultRoles(clubId);
const normalizedRoleIds = [...new Set((roleIds || []).map((id) => Number(id)).filter(Boolean))];
const roles = normalizedRoleIds.length > 0
? await ClubRole.findAll({ where: { clubId, id: normalizedRoleIds } })
: [];
if (roles.length !== normalizedRoleIds.length) {
throw new Error('Mindestens eine Rolle gehört nicht zu diesem Verein');
}
try {
await ClubUserRole.destroy({ where: { clubId, userId } });
if (roles.length > 0) {
await ClubUserRole.bulkCreate(roles.map((role, index) => ({
clubId,
userId,
clubRoleId: role.id,
isPrimary: index === 0,
})));
}
await targetUserClub.update({ role: roles[0]?.roleKey || 'member' });
} catch (error) {
if (this.isMissingRoleTableError(error)) {
await targetUserClub.update({ role: roles[0]?.roleKey || 'member' });
} else {
throw error;
}
}
return { success: true, message: 'Rollen erfolgreich aktualisiert' };
}
async setCustomPermissions(userId, clubId, customPermissions, updatedByUserId) {
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Berechtigungen');
}
const targetUserClub = await UserClub.findOne({ where: { userId, clubId } });
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Die Berechtigungen des Club-Erstellers können nicht geändert werden');
}
await targetUserClub.update({ permissions: customPermissions });
return { success: true, message: 'Berechtigungen erfolgreich aktualisiert' };
}
async setUserStatus(userId, clubId, approved, updatedByUserId) {
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern des Status');
}
const targetUserClub = await UserClub.findOne({ where: { userId, clubId } });
if (!targetUserClub) {
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
}
if (targetUserClub.isOwner) {
throw new Error('Der Status des Club-Erstellers kann nicht geändert werden');
}
await targetUserClub.update({ approved });
return { success: true, message: approved ? 'Benutzer erfolgreich aktiviert' : 'Benutzer erfolgreich deaktiviert' };
}
async getClubMembersWithPermissions(clubId, requestingUserId) {
const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read');
if (!canReadPermissions) {
throw new Error('Keine Berechtigung zum Anzeigen von Berechtigungen');
}
const userClubs = await UserClub.findAll({
where: { clubId },
include: [{ model: User, as: 'user', attributes: ['id', 'email'] }],
order: [[{ model: User, as: 'user' }, 'email', 'ASC']],
});
try {
await this.ensureDefaultRoles(clubId);
const assignments = await this.getRoleAssignments(clubId, userClubs.map((entry) => entry.userId));
const assignmentsByUserId = assignments.reduce((accumulator, assignment) => {
const key = Number(assignment.userId);
if (!accumulator[key]) {
accumulator[key] = [];
}
accumulator[key].push(assignment);
return accumulator;
}, {});
return userClubs.map((userClub) => {
const payload = this.buildRolePermissionPayload(userClub, assignmentsByUserId[Number(userClub.userId)] || []);
return {
userId: userClub.userId,
user: userClub.user,
role: payload.role,
roles: payload.roles,
isAdmin: payload.isAdmin,
isOwner: userClub.isOwner,
approved: userClub.approved,
createdAt: userClub.createdAt,
updatedAt: userClub.updatedAt,
permissions: normalizePermissions(userClub.permissions),
effectivePermissions: payload.permissions,
};
});
} catch (error) {
if (this.isMissingRoleTableError(error)) {
return userClubs.map((userClub) => {
const payload = this.buildLegacyPermissionPayload(userClub);
return {
userId: userClub.userId,
user: userClub.user,
role: payload.role,
roles: payload.roles,
isAdmin: payload.isAdmin,
isOwner: userClub.isOwner,
approved: userClub.approved,
createdAt: userClub.createdAt,
updatedAt: userClub.updatedAt,
permissions: normalizePermissions(userClub.permissions),
effectivePermissions: payload.permissions,
};
});
}
throw error;
}
}
async getClubRoles(clubId, requestingUserId) {
const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read');
if (!canReadPermissions) {
throw new Error('Keine Berechtigung zum Anzeigen von Rollen');
}
await this.ensureDefaultRoles(clubId);
return ClubRole.findAll({
where: { clubId },
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
});
}
async createClubRole(clubId, payload, requestingUserId) {
const canManagePermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Anlegen von Rollen');
}
const baseKey = slugifyRoleKey(payload.roleKey || payload.name);
const existingKeys = new Set((await ClubRole.findAll({
where: { clubId },
attributes: ['roleKey'],
})).map((role) => role.roleKey));
let roleKey = baseKey;
let suffix = 2;
while (existingKeys.has(roleKey)) {
roleKey = `${baseKey}_${suffix++}`;
}
const role = await ClubRole.create({
clubId,
roleKey,
name: String(payload.name || '').trim(),
description: String(payload.description || '').trim() || null,
permissions: normalizePermissions(payload.permissions),
isSystemRole: false,
sortOrder: Number(payload.sortOrder) || 100,
});
return role;
}
async updateClubRole(clubId, roleId, payload, requestingUserId) {
const canManagePermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Ändern von Rollen');
}
const role = await ClubRole.findOne({ where: { id: roleId, clubId } });
if (!role) {
throw new Error('Rolle nicht gefunden');
}
await role.update({
name: String(payload.name || role.name).trim(),
description: payload.description === undefined ? role.description : (String(payload.description || '').trim() || null),
permissions: payload.permissions === undefined ? role.permissions : normalizePermissions(payload.permissions),
sortOrder: payload.sortOrder === undefined ? role.sortOrder : Number(payload.sortOrder) || 100,
});
return role;
}
async deleteClubRole(clubId, roleId, requestingUserId) {
const canManagePermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'write');
if (!canManagePermissions) {
throw new Error('Keine Berechtigung zum Löschen von Rollen');
}
const role = await ClubRole.findOne({ where: { id: roleId, clubId } });
if (!role) {
throw new Error('Rolle nicht gefunden');
}
if (role.isSystemRole) {
throw new Error('Systemrollen können nicht gelöscht werden');
}
await ClubUserRole.destroy({ where: { clubId, clubRoleId: roleId } });
await role.destroy();
return { success: true, message: 'Rolle erfolgreich gelöscht' };
}
async setClubOwner(userId, clubId) {
const userClub = await UserClub.findOne({ where: { userId, clubId } });
if (!userClub) {
throw new Error('UserClub relationship not found');
}
await userClub.update({ isOwner: true, role: 'admin', approved: true });
try {
await this.ensureDefaultRoles(clubId);
const adminRole = await ClubRole.findOne({ where: { clubId, roleKey: 'admin' } });
if (adminRole) {
await ClubUserRole.destroy({ where: { clubId, userId } });
await ClubUserRole.create({
clubId,
userId,
clubRoleId: adminRole.id,
isPrimary: true,
});
}
} catch (error) {
if (!this.isMissingRoleTableError(error)) {
throw error;
}
}
}
}
export default new PermissionService();

View File

@@ -1,6 +1,6 @@
# TODO
Stand: 2026-03-17
Stand: 2026-06-22
## Abgearbeitet
@@ -9,13 +9,68 @@ Stand: 2026-03-17
- [x] Sichtbare UI-Konsistenz an Diary-Mobile-Tabs und Logs-Ansicht nachgezogen.
- [x] Live-SQL fuer neue Felder und manuelle Migrationen dokumentiert.
- [x] Scheduler- und `match_results`-Ablauf dokumentiert.
- [x] Produkttrennung fuer `tt-verein.de` und `mein-tt.de` technisch eingefuehrt.
- [x] Vereinsnavigation und Routing fuer das Club-Produkt produkt- und rechtebasiert aufgebaut.
- [x] Dashboard von Dummy-Daten auf echte Vereinsdaten fuer Aufgaben, Mitglieder, Termine und fehlende Daten umgestellt.
- [x] Aufgabenmodul mit echten Aufgaben, automatischen Vorschlaegen, Ausblenden, Archivieren, Loeschen und Benutzerzuordnung umgesetzt.
- [x] Rollen und Benutzer fuer Vereine eingefuehrt, inklusive Mehrfachrollen und menuewirksamer Rechtepruefung.
- [x] Historie fuer Vereinsbereiche umgesetzt und Aenderungen an Rollen und Benutzerzuordnungen aufgenommen.
- [x] Mitgliederbereich um Bankkonto-/SEPA-relevante Vereinsdaten erweitert.
- [x] Statistiken als echte, einklappbare Vereinsauswertungen umgesetzt.
- [x] Archiv als echte Vereinsansicht umgesetzt.
- [x] Konten und Rechnungen als echte Vereinsmodule aufgebaut.
- [x] Kommunikation als Vereinsmodul mit Vorgaengen, Verteilergruppen, Empfaengerlogik und Versandstatus umgesetzt.
- [x] Echten Mailversand fuer Kommunikation mit retry-faehiger Fehlerklassifikation, Versandprotokoll und sichtbaren Fehlermeldungen eingebaut.
- [x] Rechtebasierte Club-Navigation auf fachlich passende Module und Berechtigungen fuer Aufgaben, Kommunikation, Historie, Archiv, Konten und Rechnungen nachgezogen.
## Weiter spaeter sinnvoll
## Teilweise umgesetzt / weiter ausbauen
- [x] Groeßere Views weiter komponentisieren, vor allem `DiaryView.vue`, `MembersView.vue`, `TeamManagementView.vue`.
- [x] Verbleibende selten genutzte Alt-Styles in Spezialviews und Demo-Komponenten angleichen.
- [x] Diary-Sonderfaelle weiter schaerfen, z.B. eigene Filterchips fuer entschuldigte Teilnehmer.
- [x] Club-Views weiter haerten: Read-only-Verhalten, Reload-Zustaende und Formular-Resets in Aufgaben, Kommunikation, Konten und Rechnungen weiter auf Kantenfaelle pruefen.
- [ ] Kommunikation: SMTP-Konfiguration produktiv pruefen, reale Zustellung testen und optional Antwortadressen pro Verein nachziehen.
- [x] Rechnungen: automatische Nummernvergabe aus Vereineinstellungen fertig verdrahten und weiter absichern.
- [x] Aufgabenautomatisierung weiter ausbauen, damit noch mehr Vereinsprozesse automatisch Folgeschritte erzeugen.
- [x] Dashboard weiter verdichten, damit neue Kommunikations-, Finanz- und Archivdaten direkter sichtbar werden.
- [x] Beitraege/Zahlungen operativ zuerst verdichten statt sofort Regelwerk bauen:
- [x] Mitglieder sauber mit Beitragsgruppe und Zahlungsbezug sichtbar verknuepfen.
- [x] In der Beitraege-Ansicht pro Mitglied offene Forderungen, letzten Status und fehlende Zuordnungen sichtbar machen.
- [x] In der Zahlungen-Ansicht Forderungen, Mahnstufen und offene Aktionen als taegliche Arbeitsliste nutzbar machen.
- [x] Danach einfache manuelle Beitragslogik fuer typische Vereinsfaelle ergaenzen.
- [ ] Erst spaeter ein allgemeines Regel-/Tarifsystem mit Familienregeln, Alterslogik und Gueltigkeitszeitraeumen bauen.
## Naechste Liste
## Naechste Prioritaeten
Die neue priorisierte Restliste steht in [OPTIMIZATION_TODO.md](./OPTIMIZATION_TODO.md).
- [x] Kommunikation: Empfaengerlogik weiter ausbauen, Versandvorlagen, Verteilerfilter und Antworten dokumentieren.
- [x] Finanzen: Ausgangs- und Eingangsrechnungen weiter vervollstaendigen, Kontenbewegungen anbinden.
- [x] Vereinsbenutzer: feinere Rechte, Rollenpflege und weitere Menueeinschraenkungen vervollstaendigen.
- [x] Automatisierte Vereinsprozesse definieren und technisch als stabile Aufgabenquellen hinterlegen.
- [x] Beitraege/Zahlungen: zuerst Mitglied -> Beitragszuordnung -> Forderung -> Zahlung als durchgehenden Vereinsprozess fertigziehen.
- [x] Beitraege: Mitgliederliste mit Beitragsgruppe, offenen Forderungen und fehlenden Zuordnungen verdichten.
- [x] Zahlungen: Forderungen, Statuswechsel und Mahnlogik weiter zu einem echten Vereinsarbeitsbereich ausbauen.
- [ ] Player-Produkt `mein-tt.de` inhaltlich ausbauen, jetzt wo die Produkttrennung steht.
## Weiter spaeter sinnvoll
- [ ] Historie feiner filtern, exportieren und moduluebergreifend verlinken.
- [ ] Kommunikation um Dokumentanhaenge und Serienvorlagen erweitern.
- [ ] Vereinsarchiv um weitere Entitaeten und komfortablere Suche erweitern.
- Detailplan fuer Club-Ausbau und Absicherung: [club-outstanding-plan.md](./club-outstanding-plan.md).
- [ ] Die alte Optimierungs-Restliste bei Bedarf mit [OPTIMIZATION_TODO.md](./OPTIMIZATION_TODO.md) zusammenfuehren.
## Fehlend
- [ ] Club-Produkt: Die folgenden sechs Vereinsmodule sind in der Struktur vorhanden, aber noch nicht als echte Arbeitsbereiche umgesetzt.
- [x] Dokumente: Upload, Ordner-/Ablagestruktur, Belegbezug und schnelle Suche als echter Vereins-Dokumentenbereich.
- [x] Dokumente: Vorlagen, Versionierung und Archivierung so nachziehen, dass Satzung, Protokolle und Nachweise sauber verwaltbar sind.
- [x] Veranstaltungen: Vereinsveranstaltungen mit Fristen, Verantwortlichkeiten und Aufgabenverknuepfung als eigenes Modul umsetzen.
- [x] Veranstaltungen: Terminarten, Statuswechsel und Nacharbeit fuer Planung, Einladung und Durchfuehrung trennen.
- [x] Sponsoren: Sponsorenstamm mit Ansprechpartnern, Laufzeiten und Vertragsbezug als echte Pflegeansicht aufbauen.
- [x] Sponsoren: Sponsoringanfragen, laufende Beziehungen und zugehoerige Rechnungen in einem Arbeitsfluss verbinden.
- [x] Beiträge: Beitragssätze, Familienmodelle und Ermäßigungen als vereinfachte manuelle Regelbasis modellieren.
- [x] Beiträge: Beitragsgruppen, Beitragszuordnung und Zahlungsbezug in Mitglieder- und Finanzsicht konsistent halten.
- [x] Zahlungen: Offene Forderungen, Zahlungseingänge, Mahnstufen und Statuswechsel als tägliche Arbeitsliste weiter schärfen.
- [x] Zahlungen: Kontobewegungen, Teilzahlungen und automatische Zuordnung zu Forderungen stabil mit dem Mitgliederbezug verbinden.
- [x] Berichte: Vorstand, Finanzen, Archiv und Sponsoring mit echten Auswertungen und Exporten aus den vorhandenen Daten versorgen.
- [x] Berichte: Wiederkehrende Reportsets, PDF-Ausgabe und Vorlagen fuer Standardauswertungen vorbereiten.

View File

@@ -0,0 +1,34 @@
# Kommunikation im Verein
Stand: 2026-06-22
## Empfängerlogik
- Einzelvorgänge adressieren genau ein Mitglied.
- Gruppenvorgänge nutzen eine Verteilergruppe plus optionale Filter.
- Rundschreiben arbeiten ohne feste Gruppe und filtern direkt auf dem Vorgang.
## Verfügbare Filter
- `Nur aktive Mitglieder`
- `Nur mit E-Mail-Adresse`
- `Nur mit aktivem SEPA-Mandat`
- `Nur ohne aktives SEPA-Mandat`
Diese Filter werden beim Auflösen der Empfängerliste technisch berücksichtigt und nicht nur im Frontend angezeigt.
## Versandstatus
- `Ausstehend`: noch kein erfolgreicher Versand.
- `Gesendet`: erfolgreich zugestellt.
- `Fehlgeschlagen`: technischer oder fachlicher Fehler.
- `Übersprungen`: bewusst nicht versendet, z. B. ohne E-Mail-Adresse.
Retry ist nur erlaubt, wenn der Fehler als retryfähig klassifiziert wurde, z. B. bei Zeitüberschreitungen oder temporären SMTP-Antworten.
## Vorlagen und Antworten
- Vorlagen enthalten Name, Kategorie, Betreff-Vorlage und Text-Vorlage.
- `Platzhalter / Antworten dokumentieren` dient als technische Dokumentation für Variablen, Antwortvorgaben und Standardformulierungen pro Verein.
- Beim Schreiben einer Nachricht kann eine Vorlage direkt in Betreff und Nachrichtentext übernommen werden.
- Eingehende Rückmeldungen werden als Richtung `Eingehend` im Vorgang protokolliert.

View File

@@ -0,0 +1,129 @@
# Club-Produkt: Ausbau und Absicherung
Stand: 2026-06-25
## Ziel
Die vorhandenen Club-Module sollen nicht mehr nur "vorhanden", sondern im Alltag stabil und nachvollziehbar nutzbar sein. Der Schwerpunkt liegt jetzt auf zwei Dingen:
- Ausbau der noch klar erkennbaren Restthemen.
- Absicherung der bereits fertig wirkenden Arbeitsbereiche gegen Kantenfaelle, Rechteprobleme und unklare Zustande.
## Aktueller Fokus
- Kommunikation
- Historie
- Archiv
- Restliche Club-UI-Absicherung in den Kernviews
- Danach erst das `mein-tt.de`-Produkt inhaltlich weiter ausbauen
## Arbeitsreihenfolge
### Phase 1: Absicherung der bestehenden Club-Views
Status: in Arbeit
Ziel:
- Keine haengenden Formulare bei Reload, Clubwechsel oder Auswahlwechsel.
- Read-only-Zustaende sind sichtbar und verhindern keine Navigation.
- Lade- und Fehlermeldungen sind konsistent und eindeutig.
Konkrete Teilaufgaben:
- `ClubTasksView.vue`: Sicherstellen, dass `selectedTask` und `form` beim Clubwechsel, bei leerer Liste und nach Loesch-/Archivaktionen sauber getrennt werden.
- `ClubCommunicationView.vue`: `selectedThread`, `threadForm`, `groupForm`, `templateForm` und `messageForm` beim Clubwechsel und nach Auswahlwechseln komplett zuruecksetzen.
- `ClubAccountsView.vue`: `selectedAccount`, `selectedTransaction`, `form` und `transactionForm` in jedem Ruecksprung sauber bereinigen.
- `ClubInvoicesView.vue`: `selectedInvoice`, `selectedParty`, `invoiceForm` und `partyForm` beim Clubwechsel und bei leeren Selektionen auf Default bringen.
- Read-only-Hinweise auf jeder der vier Views vereinheitlichen, damit ein Nutzer mit fehlenden Rechten nicht erst in die Formulare klickt, um zu merken, dass keine Bearbeitung moeglich ist.
- Ladezustände und Fehlerbanner auf eine einheitliche Form bringen, damit Reload und Fehlerfall nicht unterschiedlich wirken.
- Ein kurzer Smoke-Check je View nach der Aenderung: Liste laden, Element auswaehlen, Auswahl loeschen, Club wechseln, erneut laden.
Gepruefte Kantenfaelle:
- Club wird gewechselt, waehrend ein Detailformular offen ist.
- Datenquelle liefert eine leere Liste und der zuletzt selektierte Datensatz existiert nicht mehr.
- Nutzer hat nur Leserechte, soll aber trotzdem eine klare Orientierung haben.
- Reload erfolgt waehrend ein Formular bereits mit Daten befuellt ist.
Fertig, wenn:
- Die vier Kernviews ohne manuelle Nacharbeit zwischen Liste, Detail und Neu-Anlage wechseln.
- Beim Clubwechsel keine Formularwerte aus dem vorherigen Club sichtbar bleiben.
- Read-only-Nutzer die Bereiche verstehen, ohne in kaputte Aktionen zu laufen.
### Phase 2: Kommunikation produktiv absichern
Ziel:
- Nachrichtenfluss nicht nur funktional, sondern praxisnah robust machen.
Arbeitspakete:
- SMTP real testen, inklusive Zustellprotokoll und Fehlerfaelle.
- Optional Reply-To pro Verein oder Kommunikationsvorlage sauber ergaenzen.
- Dokumentanhaenge fuer Nachrichten und Vorlagen einfuehren.
- Serienvorlagen und wiederkehrende Nachrichtentypen vorbereiten.
Fertig, wenn:
- Eine Testzustellung je Verein reproduzierbar gelingt.
- Fehlende SMTP-Konfiguration klar und frueh sichtbar wird.
- Nachrichten mit Anhaengen und Vorlagen ohne Sonderlogik im Alltag einsetzbar sind.
### Phase 3: Historie und Archiv vertiefen
Ziel:
- Vergaengliche Vorgange muessen spaeter besser auffindbar und nachvollziehbar sein.
Arbeitspakete:
- Historie nach Modulen und Vorgangstypen filtern.
- Historie exportierbar machen.
- Historie mit Zielobjekten und Querverweisen versehen.
- Archiv um weitere Entitaeten erweitern.
- Archivsuche und Schnellfilter verbessern.
Fertig, wenn:
- Vorstand oder Verwaltung einen Vorgang aus Historie oder Archiv ohne Umweg wiederfinden kann.
- Wichtige Clubobjekte nicht nur archiviert, sondern auch wieder auffindbar und verlinkt sind.
### Phase 4: Restliche Club-UX verdichten
Ziel:
- Das Dashboard und die Detailmodule sollen gleiche Sprache sprechen.
Arbeitspakete:
- Dashboard-Schnellzugriffe weiter auf Tagesgeschaeft trimmen.
- Verlinkungen zwischen Dashboard, Mitgliedern, Zahlungen, Kommunikation und Archiv schaerfen.
- Kleine Inkonsistenzen in Statusworten, Akzentfarben und Listenlabels bereinigen.
Fertig, wenn:
- Der Einstieg immer zur naechsten sinnvollen Aktion fuehrt.
- Die wichtigsten Statuswerte nicht doppelt oder widerspruechlich gezeigt werden.
### Phase 5: Player-Produkt erst danach
Ziel:
- `mein-tt.de` bekommt nur dann neue Inhalte, wenn die Club-Seite stabil ist.
Arbeitspakete:
- Anforderungen fuer Spieleransichten separat sammeln.
- Keine Club-spezifischen Workflows mehr in das Player-Produkt ziehen.
- Neue Spielerfeatures nur gegen eigene Prioritaeten und nicht als Restverwertung der Club-Roadmap planen.
## Nicht als naechstes anfassen
- Generelles Beitrags- und Tarifsystem mit Familienlogik, Alterslogik und Gueltigkeitszeitrainen.
- Weitere grosse Produktumbauten ohne klaren Nutzen fuer den Club-Alltag.
- Zusätzliche Club-Module, solange die bestehenden Workflows noch nicht absicherungsfest sind.
## Konkrete naechste Tickets
- SMTP-Test fuer Kommunikation mit realer Zieladresse und dokumentiertem Ergebnis.
- Dokumentanhaenge fuer Kommunikation und Vorlagen.
- Historie: Filter und Export.
- Archiv: weitere Objektklassen und Suche.
- Club-UI-Smoke-Check fuer Aufgaben, Kommunikation, Konten und Rechnungen.
## Abhaengigkeiten
- SMTP-Test braucht eine real erreichbare Versandkonfiguration.
- Historie-Export braucht klare Zielobjekt- und Filterdefinitionen.
- Archiv-Erweiterungen sollten auf bereits vorhandene Dokument-, Rechnungs- und Beitragsdaten aufsetzen.
## Erwartetes Ergebnis
Nach dieser Runde sind die Club-Bereiche nicht nur vorhanden, sondern im Alltag kontrollierbar, nachvollziehbar und ausreichend robust fuer den produktiven Einsatz eines Vereins. Danach kann der Fokus auf neue inhaltliche Produktarbeit wechseln.

View File

@@ -0,0 +1,31 @@
# Automatisierte Aufgabenquellen
Stand: 2026-06-22
## Stabile Quellen
- `club_requests`
Basis: Kontakt-, Probetraining-, Mitgliedschafts- und Sponsoringanfragen.
- `members`
Basis: Mitgliedsdaten, fehlende Pflichtangaben, Statuswechsel.
- `club_sepa_mandates`
Basis: fehlende, widerrufene oder ablaufrelevante SEPA-Mandate.
- `club_payment_claims`
Basis: offene, teilweise bezahlte oder überfällige Beitragsforderungen.
- `club_invoices`
Basis: eingehende und ausgehende Rechnungen mit Fälligkeiten und Status.
- `club_communication`
Basis: fehlgeschlagene Zustellversuche mit retryfähigen Fehlern.
- `calendar_events`
Basis: Termine, Fristen und Vereinsveranstaltungen.
## Technisches Verhalten
- Jede automatische Aufgabe erhält `automation_source`, `automation_key` und `source_snapshot`.
- `automation_key` dient zur Deduplizierung.
- Ausgeblendete Vorschläge wirken vereinsweit, nicht nur pro Benutzer.
- Aufgabenquellen werden im Aufgabenmodul sichtbar gemacht, damit nachvollziehbar bleibt, woher ein Vorschlag kommt.
## Ziel
Die Automatisierung soll keine Blackbox sein. Jeder Vorschlag muss auf einen stabilen Quelldatensatz zurückführbar und nach Änderungen reproduzierbar sein.

View File

@@ -0,0 +1,80 @@
# Multi-Product Umbau fuer `mein-tt.de` und `tt-verein.de`
## Summary
- Das Frontend wird von einer einheitlichen Vereins-App zu zwei klar getrennten Produkten auf gemeinsamer technischer Basis umgebaut:
- `tt-verein.de` fuer Vereinsarbeit
- `mein-tt.de` fuer einzelne Spieler
- Im ersten Schritt wird nur die Architektur und Produkttrennung umgesetzt, nicht der volle Ausbau neuer Spieler- oder Finanzmodule.
- Backend, Auth und Accounts bleiben vorerst gemeinsam; die Domain bestimmt Produktkontext, sichtbare Navigation, Standard-Startseite, zugelassene Routen und SEO.
## Implementation Changes
- Einen zentralen Produktkontext einfuehren, der beim App-Start den Host auf ein Produkt mapped.
- `tt-verein.de` => `club`
- `mein-tt.de` => `player`
- lokale Entwicklung zusaetzlich per Env-Override steuerbar, damit beide Produkte ohne DNS testbar bleiben
- Den Produktkontext zentral bereitstellen, statt `window.location` spaeter verteilt in Views zu pruefen.
- Store erweitert um `appProduct`, `appBrand`, `defaultHomeRoute`
- kleine Konfigurationsquelle fuer Produkt-Metadaten, erlaubte Routen, SEO-Basisdaten und Navigationsdefinitionen
- Routing auf Produktfaehigkeit umstellen.
- In `src/router.js` jede interne Route mit Produkt-Metadaten versehen, z. B. `products: ['club']`, `['player']`, `['club', 'player']`
- Globaler Router-Guard blockiert direkte URL-Zugriffe auf fachlich unpassende Bereiche
- Bei gesperrten Routen Umleitung auf produktpassende Startseite statt stiller Anzeige
- Produktzuordnung im ersten Schritt:
- `tt-verein.de`: bestehende Vereinsbereiche bleiben zugelassen, insbesondere Mitglieder, Tagebuch, Kalender, Freigaben, Statistiken, Turniere, Spielplaene, Vereinssettings, Teamverwaltung, Abrechnung
- `mein-tt.de`: zunaechst nur persoenliche Bereiche plus Kalender
- konkret freigegeben auf `mein-tt.de`: Startseite, Login/Register/Passwort-Flows, Kalender, persoenliche Einstellungen, MyTischtennis-/click-TT-Konto, Bestellungen, Impressum/Datenschutz/Konto loeschen
- alle klar vereinszentrierten Bereiche auf `mein-tt.de` sperren, einschliesslich Club-Auswahl als Primaernavigation, Mitgliederverwaltung, Freigaben, Teamverwaltung, Vereinssettings, Billing, Turnier- und Spielplan-Arbeitsflaechen
- falls einzelne heute technisch noch `currentClub` voraussetzen, bleiben sie auf `mein-tt.de` zunaechst ebenfalls gesperrt, bis sie produktneutral gemacht sind
- Navigation aus `App.vue` heraus in deklarative Produktnavigation ueberfuehren.
- keine fest verdrahteten Link-Bloecke mehr pro Template-Abschnitt
- Menue wird aus einer Konfigurationsliste gerendert: Label, Route, Icon, Permission-Regeln, Produktzuordnung
- `tt-verein.de` behaelt Club-Selektor und vereinszentrierte Sidebar
- `mein-tt.de` erhaelt eine reduzierte persoenliche Navigation ohne Club-Selektor als dominantes Element
- Onboarding/Startverhalten trennen.
- `tt-verein.de`: nach Login weiter club-zentriert; wenn kein Verein gewaehlt/verfuegbar, Club-Auswahl bzw. `createclub`
- `mein-tt.de`: nach Login auf persoenliche Startseite; kein erzwungener Club-Schritt
- bestehende Logik, die Navigation erst bei `selectedClub` sichtbar macht, wird fuer das Player-Produkt entkoppelt
- Public Surface und SEO pro Produkt trennen.
- `src/utils/seo.js` nicht mehr auf `https://tt-tagebuch.de` fest verdrahten
- produktabhaengige Canonical-URL, Seitentitel, OG-Daten und Standardbeschreibungen
- `index.html` ohne feste Canonical auf alte Domain
- oeffentliche Landingpages auf `tt-verein.de` vereinszentriert belassen
- `mein-tt.de` bekommt eine eigene reduzierte oeffentliche Positionierung fuer Spieler, auch wenn die neuen Spielerfeatures fachlich erst spaeter kommen
## Public Interfaces / Config Changes
- Neue zentrale Produktkonfiguration, z. B. in einer Datei wie `src/config/products.js`
- Hostname -> Produkt
- Produktname/Brand
- Default-Route
- erlaubte Routen
- SEO-Basiswerte
- Route-Meta wird erweitert um Produkt-Sichtbarkeit.
- Optionale Env-Variablen fuer lokale und Deployment-seitige Steuerung:
- Produkt-Override fuer lokale Entwicklung
- optionale Host-/Canonical-Basis-URLs je Produkt
## Test Plan
- Router-Guard:
- `mein-tt.de` blockiert direkte Aufrufe von `/members`, `/billing`, `/club-settings`, `/team-management`
- `tt-verein.de` laesst diese Routen bei Auth weiter zu
- Navigation:
- auf `mein-tt.de` erscheinen keine vereinszentrierten Menuepunkte
- auf `tt-verein.de` bleibt die bestehende Vereinsnavigation erhalten
- Onboarding:
- Login auf `mein-tt.de` landet ohne Club-Zwang auf persoenlicher Startseite
- Login auf `tt-verein.de` bleibt club-zentriert
- SEO:
- Canonical, Title und Description wechseln je Host korrekt
- oeffentliche Vereins-Landingpages referenzieren `tt-verein.de`, nicht mehr `tt-tagebuch.de`
- Regression:
- bestehende Auth-Flows, Club-Wechsel und Permission-basierte Vereinsnavigation funktionieren auf `tt-verein.de` unveraendert weiter
## Assumptions
- Gemeinsames Backend und gemeinsame Accounts bleiben im ersten Schritt bestehen.
- Die Domain-Trennung ist fachlich hart: unpassende Bereiche werden nicht nur im Menue versteckt, sondern per Routing gesperrt.
- `mein-tt.de` ist im ersten Schritt bewusst schmal und zeigt nur vorhandene persoenliche bzw. unkritische Bereiche plus Kalender.
- Neue Spielerfunktionen wie individuelles Trainingsprogramm und Ziele sowie neue Vereinsmodule wie Budget/Finanzuebersichten/Rechnungen werden erst im naechsten Ausbau auf diese Architektur aufgesetzt.
- Die bisherige Marke `tt-tagebuch.de` wird technisch nicht mehr als primaere oeffentliche Canonical-Basis behandelt, sobald die neuen Domains live sind.

View File

@@ -11,65 +11,48 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>Trainingstagebuch Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere</title>
<meta name="description" content="Trainingstagebuch: Vereinssoftware für Tischtennisvereine Mitgliederverwaltung und Mitgliederprofile, Trainingsplanung, Trainingstagebuch, Turniere, Mannschaften, Statistiken, MyTischtennis-Anbindung." />
<title>TT Verein und Mein TT</title>
<meta name="description" content="Tischtennis-Software fuer Vereine und Spieler." />
<meta name="robots" content="index,follow" />
<link rel="canonical" href="https://tt-tagebuch.de/" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Trainingstagebuch" />
<meta property="og:title" content="Trainingstagebuch Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere" />
<meta property="og:description" content="Vereinssoftware für Tischtennisvereine: Mitgliederverwaltung, Mitgliederprofile, Trainingsplanung, Turniere, Mannschaften, Statistiken, MyTischtennis-Anbindung." />
<meta property="og:url" content="https://tt-tagebuch.de/" />
<meta property="og:image" content="https://tt-tagebuch.de/android-chrome-512x512.png" />
<meta property="og:site_name" content="TT Verein und Mein TT" />
<meta property="og:title" content="TT Verein und Mein TT" />
<meta property="og:description" content="Tischtennis-Software fuer Vereine und Spieler." />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Trainingstagebuch Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere" />
<meta name="twitter:description" content="Vereinssoftware für Tischtennisvereine: Mitgliederverwaltung, Mitgliederprofile, Trainingsplanung, Turniere, Mannschaften, Statistiken, MyTischtennis-Anbindung." />
<meta name="twitter:image" content="https://tt-tagebuch.de/android-chrome-512x512.png" />
<meta name="twitter:title" content="TT Verein und Mein TT" />
<meta name="twitter:description" content="Tischtennis-Software fuer Vereine und Spieler." />
<!-- JSON-LD: Website + Organization -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "Trainingstagebuch",
"url": "https://tt-tagebuch.de/",
"potentialAction": {
"@type": "SearchAction",
"target": "https://tt-tagebuch.de/?q={search_term_string}",
"query-input": "required name=search_term_string"
}
"name": "TT Verein und Mein TT"
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "Trainingstagebuch",
"name": "TT Verein und Mein TT",
"applicationCategory": "SportsApplication",
"operatingSystem": "Web",
"description": "Umfassende Vereinsverwaltung mit Mitgliederverwaltung, Trainingsgruppen, Trainingszeiten, Trainingstagebuch, Turnierorganisation (intern, offen, offiziell), Team-Management, MyTischtennis-Integration, Statistiken und flexiblen Berechtigungssystemen DSGVOkonform und einfach zu bedienen.",
"description": "Tischtennis-Software fuer Vereine und Spieler mit getrennten Produktoberflaechen.",
"featureList": [
"Mitgliederverwaltung & Mitgliederprofile",
"Trainingsgruppen & Trainingszeiten",
"Trainingstagebuch & Dokumentation",
"Turniere (intern, offen, offiziell)",
"Team-Management & Ligen",
"MyTischtennis-Integration",
"Statistiken & Auswertungen",
"Rollen & Berechtigungssystem",
"PDF-Export",
"Aktivitätsprotokoll"
"Vereinsverwaltung fuer Tischtennisvereine",
"Persoenliche Spieleroberflaeche",
"Kalender und Turnierbezug",
"myTischtennis-Integration"
],
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR"
},
"url": "https://tt-tagebuch.de/"
}
}
</script>
<script type="application/ld+json">

View File

@@ -11,9 +11,11 @@
"axios": "^1.7.3",
"core-js": "^3.8.3",
"crypto-js": "^4.2.0",
"docx": "^9.7.1",
"html2canvas": "^1.4.1",
"jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.2",
"jszip": "^3.10.1",
"node-cron": "^4.2.1",
"pdfjs-dist": "^5.6.205",
"socket.io-client": "^4.8.1",
@@ -1708,6 +1710,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz",
"integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/pako": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
@@ -2120,6 +2131,12 @@
"url": "https://opencollective.com/core-js"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2226,6 +2243,41 @@
"node": ">=0.10"
}
},
"node_modules/docx": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
"license": "MIT",
"dependencies": {
"@types/node": "^25.2.3",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.15.tgz",
"integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/dompurify": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
@@ -2904,6 +2956,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -2960,6 +3022,12 @@
"node": ">= 4"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/immutable": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
@@ -2998,7 +3066,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/iobuffer": {
@@ -3041,6 +3108,12 @@
"node": ">=0.12.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3108,6 +3181,24 @@
"jspdf": "^2 || ^3 || ^4"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/jszip/node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3132,6 +3223,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -3216,6 +3316,12 @@
"node": ">= 0.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -3506,6 +3612,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -3532,6 +3644,21 @@
"performance-now": "^2.1.0"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3618,6 +3745,12 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/sass": {
"version": "1.89.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz",
@@ -3680,6 +3813,15 @@
}
}
},
"node_modules/sax": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -3693,6 +3835,12 @@
"node": ">=10"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -3803,6 +3951,15 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -3953,6 +4110,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -3967,7 +4130,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/utrie": {
@@ -4288,6 +4450,24 @@
}
}
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",

View File

@@ -19,9 +19,11 @@
"axios": "^1.7.3",
"core-js": "^3.8.3",
"crypto-js": "^4.2.0",
"docx": "^9.7.1",
"html2canvas": "^1.4.1",
"jspdf": "^4.0.0",
"jspdf-autotable": "^5.0.2",
"jszip": "^3.10.1",
"node-cron": "^4.2.1",
"pdfjs-dist": "^5.6.205",
"socket.io-client": "^4.8.1",

View File

@@ -0,0 +1,464 @@
ALTER TABLE `clubs`
ADD COLUMN IF NOT EXISTS `short_name` varchar(120) NULL,
ADD COLUMN IF NOT EXISTS `legal_name` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `club_number` varchar(64) NULL,
ADD COLUMN IF NOT EXISTS `email` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `phone` varchar(80) NULL,
ADD COLUMN IF NOT EXISTS `website` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `street` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `postal_code` varchar(24) NULL,
ADD COLUMN IF NOT EXISTS `city` varchar(120) NULL,
ADD COLUMN IF NOT EXISTS `country_code` varchar(2) NOT NULL DEFAULT 'DE',
ADD COLUMN IF NOT EXISTS `chairperson_name` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `treasurer_name` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `youth_manager_name` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `creditor_identifier` varchar(64) NULL,
ADD COLUMN IF NOT EXISTS `billing_email` varchar(255) NULL,
ADD COLUMN IF NOT EXISTS `iban` varchar(34) NULL,
ADD COLUMN IF NOT EXISTS `bic` varchar(11) NULL,
ADD COLUMN IF NOT EXISTS `fee_rules` json NULL,
ADD COLUMN IF NOT EXISTS `outgoing_invoice_prefix` varchar(24) NOT NULL DEFAULT 'RE',
ADD COLUMN IF NOT EXISTS `outgoing_invoice_next_number` int NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS `incoming_invoice_prefix` varchar(24) NOT NULL DEFAULT 'EI',
ADD COLUMN IF NOT EXISTS `incoming_invoice_next_number` int NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS `is_archived` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `archived_at` datetime NULL;
ALTER TABLE `member`
ADD COLUMN IF NOT EXISTS `member_number` varchar(64) NULL,
ADD COLUMN IF NOT EXISTS `membership_status` varchar(32) NOT NULL DEFAULT 'active',
ADD COLUMN IF NOT EXISTS `membership_type` varchar(32) NOT NULL DEFAULT 'regular',
ADD COLUMN IF NOT EXISTS `joined_on` date NULL,
ADD COLUMN IF NOT EXISTS `left_on` date NULL,
ADD COLUMN IF NOT EXISTS `contribution_group_code` varchar(64) NULL,
ADD COLUMN IF NOT EXISTS `needs_sepa_mandate` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `sepa_mandate_reference` varchar(80) NULL,
ADD COLUMN IF NOT EXISTS `is_archived` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `archived_at` datetime NULL,
ADD COLUMN IF NOT EXISTS `archived_reason` text NULL;
CREATE TABLE IF NOT EXISTS `club_requests` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`request_type` varchar(32) NOT NULL,
`status` varchar(32) NOT NULL DEFAULT 'open',
`workflow_stage` varchar(64) NULL,
`priority` varchar(16) NOT NULL DEFAULT 'normal',
`subject` varchar(255) NULL,
`first_name` varchar(120) NULL,
`last_name` varchar(120) NULL,
`email` varchar(255) NULL,
`phone` varchar(80) NULL,
`message` text NULL,
`source_system` varchar(64) NULL,
`received_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`assigned_user_id` bigint NULL,
`assigned_member_id` bigint NULL,
`converted_member_id` bigint NULL,
`closed_at` datetime NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_requests_club_status` (`club_id`, `status`, `request_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_distribution_groups` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`name` varchar(255) NOT NULL,
`description` text NULL,
`group_type` varchar(32) NOT NULL DEFAULT 'custom',
`is_system_group` tinyint(1) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_distribution_groups_club` (`club_id`, `name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_distribution_group_members` (
`id` bigint NOT NULL AUTO_INCREMENT,
`group_id` bigint NOT NULL,
`member_id` bigint NOT NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_club_distribution_group_member` (`group_id`, `member_id`),
KEY `idx_club_distribution_group_members_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_communication_threads` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`thread_type` varchar(32) NOT NULL DEFAULT 'direct',
`subject` varchar(255) NOT NULL,
`status` varchar(32) NOT NULL DEFAULT 'draft',
`created_by_user_id` bigint NULL,
`distribution_group_id` bigint NULL,
`recipient_member_id` bigint NULL,
`scheduled_at` datetime NULL,
`sent_at` datetime NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_communication_threads_club` (`club_id`, `status`, `thread_type`),
KEY `idx_club_communication_threads_group` (`distribution_group_id`),
KEY `idx_club_communication_threads_member` (`recipient_member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_communication_messages` (
`id` bigint NOT NULL AUTO_INCREMENT,
`thread_id` bigint NOT NULL,
`club_id` bigint NOT NULL,
`message_type` varchar(32) NOT NULL DEFAULT 'message',
`direction` varchar(32) NOT NULL DEFAULT 'outbound',
`body` text NOT NULL,
`created_by_user_id` bigint NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_communication_messages_thread` (`thread_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_communication_recipients` (
`id` bigint NOT NULL AUTO_INCREMENT,
`thread_id` bigint NOT NULL,
`club_id` bigint NOT NULL,
`member_id` bigint NULL,
`recipient_name` varchar(255) NOT NULL,
`email_snapshot` varchar(255) NULL,
`delivery_status` varchar(32) NOT NULL DEFAULT 'pending',
`delivered_at` datetime NULL,
`last_attempt_at` datetime NULL,
`attempt_count` int NOT NULL DEFAULT 0,
`retryable` tinyint(1) NOT NULL DEFAULT 0,
`error_code` varchar(64) NULL,
`transport_message_id` varchar(255) NULL,
`error_message` text NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_communication_recipients_thread` (`thread_id`, `delivery_status`),
KEY `idx_club_communication_recipients_member` (`member_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_communication_delivery_logs` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`thread_id` bigint NOT NULL,
`recipient_id` bigint NULL,
`created_by_user_id` bigint NULL,
`status` varchar(32) NOT NULL DEFAULT 'failed',
`attempt_no` int NOT NULL DEFAULT 1,
`retryable` tinyint(1) NOT NULL DEFAULT 0,
`error_code` varchar(64) NULL,
`error_message` text NULL,
`transport_message_id` varchar(255) NULL,
`transport_response` text NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_communication_delivery_logs_thread` (`thread_id`, `created_at`),
KEY `idx_club_communication_delivery_logs_recipient` (`recipient_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `club_distribution_groups`
ADD COLUMN IF NOT EXISTS `filter_definition` json NULL;
ALTER TABLE `club_communication_threads`
ADD COLUMN IF NOT EXISTS `recipient_filters` json NULL;
CREATE TABLE IF NOT EXISTS `club_communication_templates` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`name` varchar(160) NOT NULL,
`category` varchar(64) NOT NULL DEFAULT 'general',
`subject_template` varchar(255) NULL,
`body_template` text NULL,
`variables_hint` text NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_communication_templates_club` (`club_id`, `category`, `name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_account_transactions` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`account_id` bigint NOT NULL,
`invoice_id` bigint NULL,
`created_by_user_id` bigint NULL,
`direction` varchar(16) NOT NULL DEFAULT 'credit',
`booking_type` varchar(32) NOT NULL DEFAULT 'manual',
`status` varchar(32) NOT NULL DEFAULT 'booked',
`booking_date` date NOT NULL,
`value_date` date NULL,
`amount_cents` bigint NOT NULL DEFAULT 0,
`currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
`reference` varchar(255) NULL,
`notes` text NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_account_transactions_club_date` (`club_id`, `booking_date`, `status`),
KEY `idx_club_account_transactions_account_date` (`account_id`, `booking_date`),
KEY `idx_club_account_transactions_invoice` (`invoice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_request_notes` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_request_id` bigint NOT NULL,
`note_type` varchar(32) NOT NULL DEFAULT 'internal',
`body` text NOT NULL,
`created_by_user_id` bigint NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_request_notes_request` (`club_request_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_tasks` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`title` varchar(255) NOT NULL,
`task_type` varchar(64) NULL,
`description` text NULL,
`status` varchar(32) NOT NULL DEFAULT 'open',
`priority` varchar(16) NOT NULL DEFAULT 'normal',
`due_at` datetime NULL,
`remind_at` datetime NULL,
`created_by_user_id` bigint NULL,
`assigned_user_id` bigint NULL,
`automation_source` varchar(64) NULL,
`automation_key` varchar(255) NULL,
`related_entity_type` varchar(32) NULL,
`related_entity_id` bigint NULL,
`completed_at` datetime NULL,
`archived_at` datetime NULL,
`source_snapshot` json NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_tasks_club_status_due` (`club_id`, `status`, `due_at`),
UNIQUE KEY `uq_club_tasks_automation_key` (`club_id`, `automation_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_task_suppressions` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`automation_key` varchar(255) NOT NULL,
`suppression_token` varchar(255) NOT NULL,
`dismissed_by_user_id` bigint NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_club_task_suppressions_key` (`club_id`, `automation_key`),
KEY `idx_club_task_suppressions_lookup` (`club_id`, `automation_key`, `suppression_token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_roles` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`role_key` varchar(64) NOT NULL,
`name` varchar(120) NOT NULL,
`description` text NULL,
`permissions` json NOT NULL,
`is_system_role` tinyint(1) NOT NULL DEFAULT 0,
`sort_order` int NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_club_roles_key` (`club_id`, `role_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_user_roles` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`user_id` bigint NOT NULL,
`club_role_id` bigint NOT NULL,
`is_primary` tinyint(1) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_club_user_roles_assignment` (`club_id`, `user_id`, `club_role_id`),
KEY `idx_club_user_roles_user` (`club_id`, `user_id`),
KEY `idx_club_user_roles_role` (`club_role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_sepa_mandates` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`member_id` bigint NULL,
`debtor_name` varchar(255) NOT NULL,
`iban` varchar(34) NOT NULL,
`bic` varchar(11) NULL,
`mandate_reference` varchar(80) NOT NULL,
`signed_on` date NULL,
`valid_from` date NULL,
`revoked_at` datetime NULL,
`status` varchar(32) NOT NULL DEFAULT 'active',
`history_note` text NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_sepa_mandates_club_member` (`club_id`, `member_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_payment_claims` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`member_id` bigint NULL,
`fee_rule_id` bigint NULL,
`claim_type` varchar(32) NOT NULL DEFAULT 'membership_fee',
`status` varchar(32) NOT NULL DEFAULT 'open',
`due_on` date NOT NULL,
`amount_cents` bigint NOT NULL,
`currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
`reminder_level` int NOT NULL DEFAULT 0,
`last_reminder_at` datetime NULL,
`notes` text NULL,
`settled_at` datetime NULL,
`archived_at` datetime NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_payment_claims_club_status_due` (`club_id`, `status`, `due_on`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_accounts` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`name` varchar(160) NOT NULL,
`account_holder` varchar(255) NULL,
`bank_name` varchar(160) NULL,
`iban` varchar(34) NULL,
`bic` varchar(11) NULL,
`account_type` varchar(16) NOT NULL DEFAULT 'bank',
`usage_type` varchar(32) NOT NULL DEFAULT 'general',
`currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
`allow_sepa_collections` tinyint(1) NOT NULL DEFAULT 0,
`allow_outgoing_payments` tinyint(1) NOT NULL DEFAULT 1,
`is_default` tinyint(1) NOT NULL DEFAULT 0,
`status` varchar(16) NOT NULL DEFAULT 'active',
`notes` text NULL,
`sort_order` int NOT NULL DEFAULT 0,
`archived_at` datetime NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_accounts_club_status` (`club_id`, `status`, `account_type`),
KEY `idx_club_accounts_default` (`club_id`, `is_default`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_invoice_parties` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`party_type` varchar(32) NOT NULL DEFAULT 'customer',
`status` varchar(32) NOT NULL DEFAULT 'active',
`name` varchar(255) NOT NULL,
`contract_reference` varchar(120) NULL,
`valid_from` date NULL,
`valid_to` date NULL,
`contact_name` varchar(255) NULL,
`email` varchar(255) NULL,
`phone` varchar(80) NULL,
`street` varchar(255) NULL,
`postal_code` varchar(24) NULL,
`city` varchar(120) NULL,
`country_code` varchar(2) NOT NULL DEFAULT 'DE',
`iban` varchar(34) NULL,
`bic` varchar(11) NULL,
`tax_identifier` varchar(64) NULL,
`notes` text NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_invoice_parties_club_type` (`club_id`, `party_type`),
KEY `idx_club_invoice_parties_club_status` (`club_id`, `status`),
KEY `idx_club_invoice_parties_club_valid_from` (`club_id`, `valid_from`),
KEY `idx_club_invoice_parties_club_valid_to` (`club_id`, `valid_to`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `club_invoices` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`invoice_direction` varchar(16) NOT NULL,
`invoice_type` varchar(32) NOT NULL,
`status` varchar(32) NOT NULL DEFAULT 'draft',
`invoice_number` varchar(64) NULL,
`external_reference` varchar(255) NULL,
`party_id` bigint NULL,
`account_id` bigint NULL,
`issued_on` date NULL,
`due_on` date NULL,
`paid_on` date NULL,
`net_amount_cents` bigint NOT NULL DEFAULT 0,
`tax_amount_cents` bigint NOT NULL DEFAULT 0,
`gross_amount_cents` bigint NOT NULL DEFAULT 0,
`currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
`description` text NULL,
`document_id` bigint NULL,
`created_by_user_id` bigint NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`archived_at` datetime NULL,
PRIMARY KEY (`id`),
KEY `idx_club_invoices_club_direction_status` (`club_id`, `invoice_direction`, `status`, `due_on`),
UNIQUE KEY `uq_club_invoices_number` (`club_id`, `invoice_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET @club_invoices_number_index_exists := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'club_invoices'
AND index_name = 'uq_club_invoices_number'
);
SET @club_invoices_number_index_sql := IF(
@club_invoices_number_index_exists = 0,
'ALTER TABLE `club_invoices` ADD UNIQUE KEY `uq_club_invoices_number` (`club_id`, `invoice_number`)',
'SELECT 1'
);
PREPARE club_invoices_number_index_stmt FROM @club_invoices_number_index_sql;
EXECUTE club_invoices_number_index_stmt;
DEALLOCATE PREPARE club_invoices_number_index_stmt;
CREATE TABLE IF NOT EXISTS `club_invoice_items` (
`id` bigint NOT NULL AUTO_INCREMENT,
`invoice_id` bigint NOT NULL,
`line_no` int NOT NULL DEFAULT 1,
`description` text NOT NULL,
`quantity` decimal(12,2) NOT NULL DEFAULT 1.00,
`unit_price_cents` bigint NOT NULL DEFAULT 0,
`tax_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
`total_cents` bigint NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_club_invoice_items_line` (`invoice_id`, `line_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE `club_communication_recipients`
ADD COLUMN IF NOT EXISTS `last_attempt_at` datetime NULL,
ADD COLUMN IF NOT EXISTS `attempt_count` int NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `retryable` tinyint(1) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS `error_code` varchar(64) NULL,
ADD COLUMN IF NOT EXISTS `transport_message_id` varchar(255) NULL;
CREATE TABLE IF NOT EXISTS `club_communication_delivery_logs` (
`id` bigint NOT NULL AUTO_INCREMENT,
`club_id` bigint NOT NULL,
`thread_id` bigint NOT NULL,
`recipient_id` bigint NULL,
`created_by_user_id` bigint NULL,
`status` varchar(32) NOT NULL DEFAULT 'failed',
`attempt_no` int NOT NULL DEFAULT 1,
`retryable` tinyint(1) NOT NULL DEFAULT 0,
`error_code` varchar(64) NULL,
`error_message` text NULL,
`transport_message_id` varchar(255) NULL,
`transport_response` text NULL,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_club_communication_delivery_logs_thread` (`thread_id`, `created_at`),
KEY `idx_club_communication_delivery_logs_recipient` (`recipient_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,853 @@
BEGIN;
SET client_encoding = 'UTF8';
SET standard_conforming_strings = on;
ALTER TABLE IF EXISTS clubs
ADD COLUMN IF NOT EXISTS short_name varchar(120),
ADD COLUMN IF NOT EXISTS legal_name varchar(255),
ADD COLUMN IF NOT EXISTS club_number varchar(64),
ADD COLUMN IF NOT EXISTS email varchar(255),
ADD COLUMN IF NOT EXISTS phone varchar(80),
ADD COLUMN IF NOT EXISTS website varchar(255),
ADD COLUMN IF NOT EXISTS street varchar(255),
ADD COLUMN IF NOT EXISTS postal_code varchar(24),
ADD COLUMN IF NOT EXISTS city varchar(120),
ADD COLUMN IF NOT EXISTS country_code varchar(2) DEFAULT 'DE',
ADD COLUMN IF NOT EXISTS chairperson_name varchar(255),
ADD COLUMN IF NOT EXISTS treasurer_name varchar(255),
ADD COLUMN IF NOT EXISTS youth_manager_name varchar(255),
ADD COLUMN IF NOT EXISTS creditor_identifier varchar(64),
ADD COLUMN IF NOT EXISTS billing_email varchar(255),
ADD COLUMN IF NOT EXISTS iban varchar(34),
ADD COLUMN IF NOT EXISTS bic varchar(11),
ADD COLUMN IF NOT EXISTS fee_rules jsonb,
ADD COLUMN IF NOT EXISTS outgoing_invoice_prefix varchar(24) NOT NULL DEFAULT 'RE',
ADD COLUMN IF NOT EXISTS outgoing_invoice_next_number integer NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS incoming_invoice_prefix varchar(24) NOT NULL DEFAULT 'EI',
ADD COLUMN IF NOT EXISTS incoming_invoice_next_number integer NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS archived_at timestamptz;
ALTER TABLE IF EXISTS member
ADD COLUMN IF NOT EXISTS member_number varchar(64),
ADD COLUMN IF NOT EXISTS membership_status varchar(32) NOT NULL DEFAULT 'active',
ADD COLUMN IF NOT EXISTS membership_type varchar(32) NOT NULL DEFAULT 'regular',
ADD COLUMN IF NOT EXISTS joined_on date,
ADD COLUMN IF NOT EXISTS left_on date,
ADD COLUMN IF NOT EXISTS contribution_group_code varchar(64),
ADD COLUMN IF NOT EXISTS needs_sepa_mandate boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS sepa_mandate_reference varchar(80),
ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS archived_at timestamptz,
ADD COLUMN IF NOT EXISTS archived_reason text;
CREATE TABLE IF NOT EXISTS club_requests (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
request_type varchar(32) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'open',
workflow_stage varchar(64),
priority varchar(16) NOT NULL DEFAULT 'normal',
source_system varchar(64),
source_reference varchar(255),
subject varchar(255),
first_name varchar(120),
last_name varchar(120),
email varchar(255),
phone varchar(80),
birthdate date,
message text,
requested_membership_type varchar(32),
assigned_user_id bigint,
assigned_member_id bigint,
converted_member_id bigint,
received_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS club_distribution_groups (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
name varchar(255) NOT NULL,
description text,
group_type varchar(32) NOT NULL DEFAULT 'custom',
is_system_group boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_distribution_groups_club
ON club_distribution_groups (club_id, name);
CREATE TABLE IF NOT EXISTS club_distribution_group_members (
id bigserial PRIMARY KEY,
group_id bigint NOT NULL,
member_id bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (group_id, member_id)
);
CREATE INDEX IF NOT EXISTS idx_club_distribution_group_members_member
ON club_distribution_group_members (member_id);
CREATE TABLE IF NOT EXISTS club_communication_threads (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
thread_type varchar(32) NOT NULL DEFAULT 'direct',
subject varchar(255) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'draft',
created_by_user_id bigint,
distribution_group_id bigint,
recipient_member_id bigint,
scheduled_at timestamptz,
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_threads_club
ON club_communication_threads (club_id, status, thread_type);
CREATE TABLE IF NOT EXISTS club_communication_messages (
id bigserial PRIMARY KEY,
thread_id bigint NOT NULL,
club_id bigint NOT NULL,
message_type varchar(32) NOT NULL DEFAULT 'message',
direction varchar(32) NOT NULL DEFAULT 'outbound',
body text NOT NULL,
created_by_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_messages_thread
ON club_communication_messages (thread_id, created_at DESC);
CREATE TABLE IF NOT EXISTS club_communication_recipients (
id bigserial PRIMARY KEY,
thread_id bigint NOT NULL,
club_id bigint NOT NULL,
member_id bigint,
recipient_name varchar(255) NOT NULL,
email_snapshot varchar(255),
delivery_status varchar(32) NOT NULL DEFAULT 'pending',
delivered_at timestamptz,
last_attempt_at timestamptz,
attempt_count integer NOT NULL DEFAULT 0,
retryable boolean NOT NULL DEFAULT false,
error_code varchar(64),
transport_message_id varchar(255),
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_recipients_thread
ON club_communication_recipients (thread_id, delivery_status);
CREATE TABLE IF NOT EXISTS club_communication_delivery_logs (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
thread_id bigint NOT NULL,
recipient_id bigint,
created_by_user_id bigint,
status varchar(32) NOT NULL DEFAULT 'failed',
attempt_no integer NOT NULL DEFAULT 1,
retryable boolean NOT NULL DEFAULT false,
error_code varchar(64),
error_message text,
transport_message_id varchar(255),
transport_response text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_thread
ON club_communication_delivery_logs (thread_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_recipient
ON club_communication_delivery_logs (recipient_id);
CREATE INDEX IF NOT EXISTS idx_club_requests_club_status
ON club_requests (club_id, status, request_type);
CREATE TABLE IF NOT EXISTS club_request_notes (
id bigserial PRIMARY KEY,
club_request_id bigint NOT NULL,
note_type varchar(32) NOT NULL DEFAULT 'internal',
body text NOT NULL,
created_by_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_request_notes_request
ON club_request_notes (club_request_id, created_at DESC);
CREATE TABLE IF NOT EXISTS club_distribution_groups (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
name varchar(160) NOT NULL,
description text,
group_type varchar(32) NOT NULL DEFAULT 'manual',
is_system_group boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_distribution_groups_name
ON club_distribution_groups (club_id, lower(name));
CREATE TABLE IF NOT EXISTS club_distribution_group_members (
id bigserial PRIMARY KEY,
distribution_group_id bigint NOT NULL,
member_id bigint,
user_id bigint,
email varchar(255),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_distribution_group_members_group
ON club_distribution_group_members (distribution_group_id);
CREATE TABLE IF NOT EXISTS club_communication_threads (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
thread_type varchar(32) NOT NULL,
subject varchar(255) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'draft',
created_by_user_id bigint,
scheduled_at timestamptz,
sent_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_threads_club_status
ON club_communication_threads (club_id, status, thread_type);
CREATE TABLE IF NOT EXISTS club_communication_recipients (
id bigserial PRIMARY KEY,
thread_id bigint NOT NULL,
member_id bigint,
user_id bigint,
distribution_group_id bigint,
email varchar(255),
delivery_status varchar(32) NOT NULL DEFAULT 'pending',
delivered_at timestamptz,
read_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_recipients_thread
ON club_communication_recipients (thread_id, delivery_status);
CREATE TABLE IF NOT EXISTS club_communication_messages (
id bigserial PRIMARY KEY,
thread_id bigint NOT NULL,
sender_user_id bigint,
sender_member_id bigint,
body text NOT NULL,
body_format varchar(16) NOT NULL DEFAULT 'plain',
sent_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_messages_thread
ON club_communication_messages (thread_id, sent_at);
CREATE TABLE IF NOT EXISTS club_events (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
event_type varchar(32) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'planned',
title varchar(255) NOT NULL,
description text,
location varchar(255),
starts_at timestamptz NOT NULL,
ends_at timestamptz,
registration_deadline timestamptz,
organizer_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_club_events_club_starts
ON club_events (club_id, starts_at);
CREATE TABLE IF NOT EXISTS club_event_participants (
id bigserial PRIMARY KEY,
event_id bigint NOT NULL,
member_id bigint,
user_id bigint,
role_code varchar(32),
participation_status varchar(32) NOT NULL DEFAULT 'planned',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_event_participants_event
ON club_event_participants (event_id, participation_status);
CREATE TABLE IF NOT EXISTS club_documents (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
document_type varchar(32) NOT NULL,
title varchar(255) NOT NULL,
description text,
status varchar(32) NOT NULL DEFAULT 'active',
visibility_scope varchar(32) NOT NULL DEFAULT 'board',
owner_user_id bigint,
current_version_no integer NOT NULL DEFAULT 1,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_documents_club_type
ON club_documents (club_id, document_type, status);
CREATE TABLE IF NOT EXISTS club_document_versions (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL,
version_no integer NOT NULL,
file_name varchar(255) NOT NULL,
storage_path varchar(500) NOT NULL,
mime_type varchar(120),
file_size_bytes bigint,
checksum_sha256 varchar(64),
uploaded_by_user_id bigint,
uploaded_at timestamptz NOT NULL DEFAULT now(),
change_note text
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_document_versions_document_version
ON club_document_versions (document_id, version_no);
CREATE TABLE IF NOT EXISTS club_document_links (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL,
linked_entity_type varchar(32) NOT NULL,
linked_entity_id bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_document_links_entity
ON club_document_links (linked_entity_type, linked_entity_id);
CREATE TABLE IF NOT EXISTS club_tasks (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
title varchar(255) NOT NULL,
task_type varchar(64),
description text,
status varchar(32) NOT NULL DEFAULT 'open',
priority varchar(16) NOT NULL DEFAULT 'normal',
due_at timestamptz,
remind_at timestamptz,
created_by_user_id bigint,
assigned_user_id bigint,
automation_source varchar(64),
automation_key varchar(255),
related_entity_type varchar(32),
related_entity_id bigint,
source_snapshot jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz,
archived_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_club_tasks_club_status_due
ON club_tasks (club_id, status, due_at);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_tasks_automation_key
ON club_tasks (club_id, automation_key)
WHERE automation_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS club_task_suppressions (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
automation_key varchar(255) NOT NULL,
suppression_token varchar(255) NOT NULL,
dismissed_by_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_task_suppressions_key
ON club_task_suppressions (club_id, automation_key);
CREATE INDEX IF NOT EXISTS idx_club_task_suppressions_lookup
ON club_task_suppressions (club_id, automation_key, suppression_token);
CREATE TABLE IF NOT EXISTS club_sponsors (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
name varchar(255) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'lead',
website varchar(255),
email varchar(255),
phone varchar(80),
street varchar(255),
postal_code varchar(24),
city varchar(120),
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_sponsors_name
ON club_sponsors (club_id, lower(name));
CREATE TABLE IF NOT EXISTS club_sponsor_contacts (
id bigserial PRIMARY KEY,
sponsor_id bigint NOT NULL,
first_name varchar(120),
last_name varchar(120),
role_title varchar(120),
email varchar(255),
phone varchar(80),
is_primary boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_sponsor_contacts_sponsor
ON club_sponsor_contacts (sponsor_id, is_primary DESC);
CREATE TABLE IF NOT EXISTS club_sponsor_contracts (
id bigserial PRIMARY KEY,
sponsor_id bigint NOT NULL,
title varchar(255) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'draft',
starts_on date,
ends_on date,
annual_amount_cents bigint,
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
notes text,
document_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_sponsor_contracts_sponsor
ON club_sponsor_contracts (sponsor_id, status);
CREATE TABLE IF NOT EXISTS club_fee_rules (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
code varchar(64) NOT NULL,
name varchar(255) NOT NULL,
category varchar(32) NOT NULL DEFAULT 'membership',
billing_cycle varchar(16) NOT NULL DEFAULT 'monthly',
base_amount_cents bigint NOT NULL,
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
age_from integer,
age_to integer,
is_family_rule boolean NOT NULL DEFAULT false,
is_reduction_rule boolean NOT NULL DEFAULT false,
valid_from date NOT NULL DEFAULT CURRENT_DATE,
valid_to date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_fee_rules_code
ON club_fee_rules (club_id, code);
CREATE TABLE IF NOT EXISTS club_fee_rule_assignments (
id bigserial PRIMARY KEY,
fee_rule_id bigint NOT NULL,
member_id bigint NOT NULL,
valid_from date NOT NULL DEFAULT CURRENT_DATE,
valid_to date,
discount_percent numeric(5,2),
fixed_amount_cents bigint,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_fee_rule_assignments_member
ON club_fee_rule_assignments (member_id, valid_from DESC);
CREATE TABLE IF NOT EXISTS club_payment_accounts (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
account_name varchar(255) NOT NULL,
account_type varchar(32) NOT NULL DEFAULT 'bank',
iban varchar(34),
bic varchar(11),
bank_name varchar(255),
account_holder varchar(255),
is_default boolean NOT NULL DEFAULT false,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_payment_accounts_club
ON club_payment_accounts (club_id, is_default DESC, is_active DESC);
CREATE TABLE IF NOT EXISTS club_sepa_mandates (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
member_id bigint,
debtor_name varchar(255) NOT NULL,
iban varchar(34) NOT NULL,
bic varchar(11),
mandate_reference varchar(80) NOT NULL,
signed_on date,
valid_from date,
revoked_at timestamptz,
status varchar(32) NOT NULL DEFAULT 'active',
history_note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_sepa_mandates_reference
ON club_sepa_mandates (club_id, mandate_reference);
CREATE TABLE IF NOT EXISTS club_payment_claims (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
member_id bigint,
fee_rule_id bigint,
claim_type varchar(32) NOT NULL DEFAULT 'membership_fee',
status varchar(32) NOT NULL DEFAULT 'open',
due_on date NOT NULL,
amount_cents bigint NOT NULL,
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
reminder_level integer NOT NULL DEFAULT 0,
last_reminder_at timestamptz,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
settled_at timestamptz,
archived_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_club_payment_claims_club_status_due
ON club_payment_claims (club_id, status, due_on);
CREATE TABLE IF NOT EXISTS club_accounts (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
name varchar(160) NOT NULL,
account_holder varchar(255),
bank_name varchar(160),
iban varchar(34),
bic varchar(11),
account_type varchar(16) NOT NULL DEFAULT 'bank',
usage_type varchar(32) NOT NULL DEFAULT 'general',
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
allow_sepa_collections boolean NOT NULL DEFAULT false,
allow_outgoing_payments boolean NOT NULL DEFAULT true,
is_default boolean NOT NULL DEFAULT false,
status varchar(16) NOT NULL DEFAULT 'active',
notes text,
sort_order integer NOT NULL DEFAULT 0,
archived_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_accounts_club_status
ON club_accounts (club_id, status, account_type);
CREATE INDEX IF NOT EXISTS idx_club_accounts_default
ON club_accounts (club_id, is_default);
CREATE TABLE IF NOT EXISTS club_payment_entries (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
payment_claim_id bigint,
payment_account_id bigint,
entry_type varchar(32) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'booked',
booked_on date NOT NULL DEFAULT CURRENT_DATE,
amount_cents bigint NOT NULL,
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
reference_text varchar(255),
external_reference varchar(255),
created_by_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_payment_entries_club_booked
ON club_payment_entries (club_id, booked_on DESC);
CREATE TABLE IF NOT EXISTS club_invoice_parties (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
party_type varchar(32) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'active',
name varchar(255) NOT NULL,
contract_reference varchar(120),
valid_from date,
valid_to date,
contact_name varchar(255),
email varchar(255),
phone varchar(80),
street varchar(255),
postal_code varchar(24),
city varchar(120),
country_code varchar(2) DEFAULT 'DE',
iban varchar(34),
bic varchar(11),
tax_identifier varchar(64),
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_type
ON club_invoice_parties (club_id, party_type);
CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_status
ON club_invoice_parties (club_id, status);
CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_valid_from
ON club_invoice_parties (club_id, valid_from);
CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_valid_to
ON club_invoice_parties (club_id, valid_to);
CREATE TABLE IF NOT EXISTS club_invoices (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
invoice_direction varchar(16) NOT NULL,
invoice_type varchar(32) NOT NULL,
status varchar(32) NOT NULL DEFAULT 'draft',
invoice_number varchar(64),
external_reference varchar(255),
party_id bigint,
account_id bigint,
issued_on date,
due_on date,
paid_on date,
net_amount_cents bigint NOT NULL DEFAULT 0,
tax_amount_cents bigint NOT NULL DEFAULT 0,
gross_amount_cents bigint NOT NULL DEFAULT 0,
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
description text,
document_id bigint,
created_by_user_id bigint,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
archived_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_club_invoices_club_direction_status
ON club_invoices (club_id, invoice_direction, status, due_on);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_invoices_number
ON club_invoices (club_id, invoice_number)
WHERE invoice_number IS NOT NULL;
CREATE TABLE IF NOT EXISTS club_invoice_items (
id bigserial PRIMARY KEY,
invoice_id bigint NOT NULL,
line_no integer NOT NULL DEFAULT 1,
description text NOT NULL,
quantity numeric(12,2) NOT NULL DEFAULT 1,
unit_price_cents bigint NOT NULL DEFAULT 0,
tax_rate numeric(5,2) NOT NULL DEFAULT 0,
total_cents bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_invoice_items_line
ON club_invoice_items (invoice_id, line_no);
CREATE TABLE IF NOT EXISTS club_history_entries (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
entity_type varchar(32) NOT NULL,
entity_id bigint NOT NULL,
action_type varchar(32) NOT NULL,
actor_user_id bigint,
actor_member_id bigint,
old_value_json jsonb,
new_value_json jsonb,
summary text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_history_entries_club_entity
ON club_history_entries (club_id, entity_type, entity_id, created_at DESC);
CREATE TABLE IF NOT EXISTS club_user_roles (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
user_id bigint,
member_id bigint,
role_code varchar(32) NOT NULL,
valid_from date NOT NULL DEFAULT CURRENT_DATE,
valid_to date,
is_primary boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_user_roles_club_user
ON club_user_roles (club_id, user_id, role_code);
ALTER TABLE club_tasks
ADD COLUMN IF NOT EXISTS task_type varchar(64),
ADD COLUMN IF NOT EXISTS automation_source varchar(64),
ADD COLUMN IF NOT EXISTS automation_key varchar(255),
ADD COLUMN IF NOT EXISTS source_snapshot jsonb;
ALTER TABLE club_requests
ADD COLUMN IF NOT EXISTS workflow_stage varchar(64);
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_requests_type') THEN
ALTER TABLE club_requests
ADD CONSTRAINT chk_club_requests_type
CHECK (request_type IN ('contact', 'trial_training', 'membership', 'sponsoring'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_requests_status') THEN
ALTER TABLE club_requests
ADD CONSTRAINT chk_club_requests_status
CHECK (status IN ('open', 'in_progress', 'waiting', 'converted', 'rejected', 'archived'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_events_type') THEN
ALTER TABLE club_events
ADD CONSTRAINT chk_club_events_type
CHECK (event_type IN ('training', 'match', 'club_event', 'meeting', 'deadline'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_tasks_status') THEN
ALTER TABLE club_tasks
ADD CONSTRAINT chk_club_tasks_status
CHECK (status IN ('open', 'in_progress', 'waiting', 'done', 'cancelled', 'archived'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_fee_rules_cycle') THEN
ALTER TABLE club_fee_rules
ADD CONSTRAINT chk_club_fee_rules_cycle
CHECK (billing_cycle IN ('monthly', 'quarterly', 'half_yearly', 'yearly', 'one_time'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_payment_claims_status') THEN
ALTER TABLE club_payment_claims
ADD CONSTRAINT chk_club_payment_claims_status
CHECK (status IN ('open', 'partially_paid', 'paid', 'written_off', 'cancelled'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_accounts_type') THEN
ALTER TABLE club_accounts
ADD CONSTRAINT chk_club_accounts_type
CHECK (account_type IN ('bank', 'cash', 'virtual'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_accounts_usage') THEN
ALTER TABLE club_accounts
ADD CONSTRAINT chk_club_accounts_usage
CHECK (usage_type IN ('general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_accounts_status') THEN
ALTER TABLE club_accounts
ADD CONSTRAINT chk_club_accounts_status
CHECK (status IN ('active', 'inactive', 'archived'));
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_invoices_direction') THEN
ALTER TABLE club_invoices
ADD CONSTRAINT chk_club_invoices_direction
CHECK (invoice_direction IN ('incoming', 'outgoing'));
END IF;
END
$$;
ALTER TABLE IF EXISTS club_communication_recipients
ADD COLUMN IF NOT EXISTS last_attempt_at timestamptz,
ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS retryable boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS error_code varchar(64),
ADD COLUMN IF NOT EXISTS transport_message_id varchar(255);
CREATE TABLE IF NOT EXISTS club_communication_delivery_logs (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
thread_id bigint NOT NULL,
recipient_id bigint,
created_by_user_id bigint,
status varchar(32) NOT NULL DEFAULT 'failed',
attempt_no integer NOT NULL DEFAULT 1,
retryable boolean NOT NULL DEFAULT false,
error_code varchar(64),
error_message text,
transport_message_id varchar(255),
transport_response text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_thread
ON club_communication_delivery_logs (thread_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_recipient
ON club_communication_delivery_logs (recipient_id);
ALTER TABLE IF EXISTS club_distribution_groups
ADD COLUMN IF NOT EXISTS filter_definition jsonb;
ALTER TABLE IF EXISTS club_communication_threads
ADD COLUMN IF NOT EXISTS recipient_filters jsonb;
CREATE TABLE IF NOT EXISTS club_communication_templates (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
name varchar(160) NOT NULL,
category varchar(64) NOT NULL DEFAULT 'general',
subject_template varchar(255),
body_template text,
variables_hint text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_communication_templates_club
ON club_communication_templates (club_id, category, name);
CREATE TABLE IF NOT EXISTS club_account_transactions (
id bigserial PRIMARY KEY,
club_id bigint NOT NULL,
account_id bigint NOT NULL,
invoice_id bigint,
created_by_user_id bigint,
direction varchar(16) NOT NULL DEFAULT 'credit',
booking_type varchar(32) NOT NULL DEFAULT 'manual',
status varchar(32) NOT NULL DEFAULT 'booked',
booking_date date NOT NULL,
value_date date,
amount_cents bigint NOT NULL DEFAULT 0,
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
reference varchar(255),
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_club_account_transactions_club_date
ON club_account_transactions (club_id, booking_date DESC, status);
CREATE INDEX IF NOT EXISTS idx_club_account_transactions_account_date
ON club_account_transactions (account_id, booking_date DESC);
CREATE INDEX IF NOT EXISTS idx_club_account_transactions_invoice
ON club_account_transactions (invoice_id);
COMMIT;

View File

@@ -4,7 +4,7 @@
<h1>
<router-link to="/" class="home-link">
<img :src="logoUrl" alt="Logo" class="home-logo" width="24" height="24" loading="lazy" />
<span>{{ $t('app.name') }}</span>
<span>{{ appBrand }}</span>
</router-link>
</h1>
<div v-if="isAuthenticated" class="user-menu">
@@ -26,19 +26,19 @@
<span class="dropdown-icon">📦</span>
{{ $t('navigation.orders') }}
</router-link>
<button v-if="canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('PermissionsView', $t('navigation.permissions'))">
<button v-if="isFullAppProduct && canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('PermissionsView', $t('navigation.permissions'))">
<span class="dropdown-icon">🔐</span>
{{ $t('navigation.permissions') }}
</button>
<button v-if="hasPermission('members', 'write')" type="button" class="dropdown-item" @click="openUserMenuDialog('MemberTransferSettingsView', $t('navigation.memberTransfer'))">
<button v-if="isFullAppProduct && hasPermission('members', 'write')" type="button" class="dropdown-item" @click="openUserMenuDialog('MemberTransferSettingsView', $t('navigation.memberTransfer'))">
<span class="dropdown-icon">📤</span>
{{ $t('navigation.memberTransfer') }}
</button>
<button v-if="isAdmin" type="button" class="dropdown-item" @click="openUserMenuDialog('LogsView', $t('navigation.logs'))">
<button v-if="isFullAppProduct && isAdmin" type="button" class="dropdown-item" @click="openUserMenuDialog('LogsView', $t('navigation.logs'))">
<span class="dropdown-icon">📋</span>
{{ $t('navigation.logs') }}
</button>
<button v-if="canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('ClickTtView', $t('navigation.clickTtBrowser'))">
<button v-if="isFullAppProduct && canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('ClickTtView', $t('navigation.clickTtBrowser'))">
<span class="dropdown-icon">🌐</span>
{{ $t('navigation.clickTtBrowser') }}
</button>
@@ -57,13 +57,13 @@
</header>
<div class="app-container">
<aside v-if="isAuthenticated" class="sidebar" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<aside v-if="shouldRenderSidebar" class="sidebar" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<button class="sidebar-toggle" @click="toggleSidebar">
<span v-if="sidebarCollapsed"></span>
<span v-else></span>
</button>
<div class="sidebar-content">
<div class="club-selector card">
<div v-if="shouldShowClubSelector" class="club-selector card">
<h3 class="card-title">{{ $t('club.select') }}</h3>
<div class="select-group">
<select v-model="selectedClub" class="club-select" @change="handleClubSelectionChange">
@@ -74,68 +74,18 @@
</div>
</div>
<nav v-if="selectedClub" class="nav-menu">
<div class="nav-section">
<h4 class="nav-title">{{ $t('navigation.dailyBusiness') }}</h4>
<router-link v-if="hasPermission('members', 'read')" to="/members" class="nav-link" title="Mitglieder">
<span class="nav-icon">👥</span>
{{ $t('navigation.members') }}
</router-link>
<router-link v-if="hasPermission('diary', 'read')" to="/diary" class="nav-link" title="Tagebuch">
<span class="nav-icon">📝</span>
{{ $t('navigation.diary') }}
</router-link>
<router-link v-if="hasPermission('diary', 'read') || hasPermission('schedule', 'read') || hasPermission('tournaments', 'read')" to="/calendar" class="nav-link" title="Kalender">
<span class="nav-icon">📆</span>
Kalender
</router-link>
<router-link v-if="canManageApprovals" to="/pending-approvals" class="nav-link" title="Freigaben">
<span class="nav-icon"></span>
{{ $t('navigation.approvals') }}
</router-link>
<router-link v-if="hasPermission('statistics', 'read')" to="/training-stats" class="nav-link" title="Trainings-Statistik">
<span class="nav-icon">📊</span>
{{ $t('navigation.statistics') }}
</router-link>
</div>
<div class="nav-section">
<h4 class="nav-title">{{ $t('navigation.competitions') }}</h4>
<router-link v-if="hasPermission('tournaments', 'read')" to="/tournaments" class="nav-link" :title="$t('navigation.clubTournaments')">
<span class="nav-icon">🏆</span>
{{ $t('navigation.clubTournaments') }}
</router-link>
<router-link v-if="hasPermission('tournaments', 'read')" to="/tournament-participations" class="nav-link" :title="$t('navigation.tournamentParticipations')">
<span class="nav-icon">📋</span>
{{ $t('navigation.tournamentParticipations') }}
</router-link>
<router-link v-if="hasPermission('schedule', 'read')" to="/schedule" class="nav-link" title="Spielpläne">
<span class="nav-icon">📅</span>
{{ $t('navigation.schedule') }}
</router-link>
<router-link v-if="hasPermission('schedule', 'read')" to="/friendly-matches" class="nav-link" title="Freundschaftsspiele">
<span class="nav-icon">🤝</span>
Freundschaftsspiele
</router-link>
</div>
<div class="nav-section">
<h4 class="nav-title">{{ $t('navigation.settings') }}</h4>
<router-link v-if="isAdmin" to="/club-settings" class="nav-link" title="Vereinseinstellungen">
<span class="nav-icon">🏛</span>
{{ $t('navigation.clubSettings') }}
</router-link>
<router-link v-if="hasPermission('predefined_activities', 'read')" to="/predefined-activities" class="nav-link" title="Vordefinierte Aktivitäten">
<span class="nav-icon">🎯</span>
{{ $t('navigation.predefinedActivities') }}
</router-link>
<router-link v-if="hasPermission('teams', 'read')" to="/team-management" class="nav-link" title="Team-Verwaltung">
<span class="nav-icon">🧩</span>
{{ $t('navigation.teamManagement') }}
</router-link>
<router-link v-if="hasPermission('members', 'read')" to="/billing" class="nav-link" :title="$t('navigation.billing')">
<span class="nav-icon">🧾</span>
{{ $t('navigation.billing') }}
<nav v-if="sidebarSections.length" class="nav-menu">
<div v-for="section in sidebarSections" :key="section.id" class="nav-section">
<h4 class="nav-title">{{ resolveSectionTitle(section) }}</h4>
<router-link
v-for="item in section.items"
:key="item.to"
:to="item.to"
class="nav-link"
:title="resolveNavItemLabel(item)"
>
<span class="nav-icon">{{ item.icon }}</span>
{{ resolveNavItemLabel(item) }}
</router-link>
</div>
</nav>
@@ -160,6 +110,7 @@
</div>
<BaseDialog
v-if="isFullAppProduct"
v-model="showMobileClubPicker"
:title="$t('club.select')"
:max-width="420"
@@ -237,6 +188,7 @@ import InfoDialog from './components/InfoDialog.vue';
import ConfirmDialog from './components/ConfirmDialog.vue';
import BaseDialog from './components/BaseDialog.vue';
import { buildInfoConfig, buildConfirmConfig } from './utils/dialogUtils.js';
import { FULL_APP_PRODUCTS, SIDEBAR_NAVIGATION } from './config/products.js';
const DialogManager = defineAsyncComponent(() => import('./components/DialogManager.vue'));
export default {
@@ -274,7 +226,28 @@ export default {
};
},
computed: {
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'sidebarCollapsed', 'username', 'hasPermission', 'isClubOwner', 'userRole', 'language']),
...mapGetters([
'isAuthenticated',
'currentClub',
'clubs',
'sidebarCollapsed',
'username',
'hasPermission',
'isClubOwner',
'userRole',
'language',
'appProduct',
'appBrand',
]),
isClubProduct() {
return this.appProduct === 'club';
},
isFullAppProduct() {
return FULL_APP_PRODUCTS.includes(this.appProduct);
},
isPlayerProduct() {
return this.appProduct === 'player';
},
isMobileViewport() {
return this.viewportWidth <= 768;
},
@@ -303,10 +276,36 @@ export default {
viewReloadKey() {
return `${this.$route.fullPath}|${this.currentClub || 'no-club'}`;
},
shouldShowClubSelector() {
return this.isFullAppProduct;
},
shouldRenderSidebar() {
return this.isAuthenticated;
},
sidebarSections() {
const baseSections = SIDEBAR_NAVIGATION[this.appProduct] || [];
const currentSections = [];
for (const section of baseSections) {
const items = section.items.filter((item) => this.isNavItemVisible(item));
if (items.length > 0) {
currentSections.push({
...section,
items,
});
}
}
if (this.isFullAppProduct && !this.selectedClub) {
return [];
}
return currentSections;
},
},
watch: {
currentClub(newVal) {
if (newVal === 'new') {
if (this.isFullAppProduct && newVal === 'new') {
this.$router.push('/createclub');
}
if (newVal) {
@@ -337,6 +336,28 @@ export default {
}
},
methods: {
resolveSectionTitle(section) {
return section.titleKey ? this.$t(section.titleKey) : section.title;
},
resolveNavItemLabel(item) {
return item.labelKey ? this.$t(item.labelKey) : item.label;
},
isNavItemVisible(item) {
if (item.capability === 'approvals') {
return this.canManageApprovals;
}
if (item.capability === 'admin') {
return this.isAdmin;
}
if (item.permission) {
const [resource, action] = item.permission;
return this.hasPermission(resource, action);
}
if (Array.isArray(item.anyPermission)) {
return item.anyPermission.some(([resource, action]) => this.hasPermission(resource, action));
}
return true;
},
handleViewportResize() {
this.viewportWidth = window.innerWidth;
this.updateMobileClubPickerState();
@@ -399,6 +420,10 @@ export default {
},
async handleClubSelectionChange() {
if (!this.isFullAppProduct) {
return;
}
if (!this.selectedClub) {
await this.setCurrentClub(null);
this.updateMobileClubPickerState();
@@ -413,6 +438,10 @@ export default {
},
async selectClubFromMobilePicker(clubId) {
if (!this.isFullAppProduct) {
return;
}
this.selectedClub = clubId;
await this.handleClubSelectionChange();
},
@@ -423,6 +452,12 @@ export default {
return;
}
if (!this.isFullAppProduct) {
this.selectedClub = this.currentClub;
this.showMobileClubPicker = false;
return;
}
if (this.currentClub) {
this.selectedClub = this.currentClub;
this.showMobileClubPicker = false;
@@ -444,7 +479,7 @@ export default {
},
updateMobileClubPickerState() {
if (!this.isAuthenticated || !this.isMobileViewport || this.currentClub || this.$route.path === '/createclub') {
if (!this.isFullAppProduct || !this.isAuthenticated || !this.isMobileViewport || this.currentClub || this.$route.path === '/createclub') {
this.showMobileClubPicker = false;
return;
}

View File

@@ -1,6 +1,9 @@
<template>
<section class="diary-sidebar-section">
<h3>{{ $t('diary.participants') }} ({{ participants.length }})</h3>
<p class="participant-summary">
{{ $t('diary.excusedParticipants') }}: {{ excusedCount }} | {{ $t('diary.availableParticipants') }}: {{ availableParticipantCount }} | {{ $t('diary.activeMembers') }}: {{ activeMemberCount }}
</p>
<div class="participant-toolbar">
<div class="participant-toolbar-actions">
<button
@@ -112,6 +115,18 @@ export default {
type: Array,
required: true
},
excusedCount: {
type: Number,
default: 0
},
activeMemberCount: {
type: Number,
default: 0
},
availableParticipantCount: {
type: Number,
default: 0
},
galleryLoading: {
type: Boolean,
default: false
@@ -162,6 +177,12 @@ export default {
overflow-x: auto;
}
.participant-summary {
margin: -0.35rem 0 0.85rem;
color: var(--text-muted, #5f7b8b);
font-size: 0.92rem;
}
.participant-toolbar {
display: flex;
flex-direction: column;

View File

@@ -0,0 +1,163 @@
<template>
<div class="rich-text-editor" :class="{ disabled }">
<div class="editor-toolbar" v-if="!disabled">
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('bold')"><strong>B</strong></button>
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('italic')"><em>I</em></button>
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('underline')"><u>U</u></button>
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('insertUnorderedList')"> Liste</button>
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('insertOrderedList')">1. Liste</button>
<button type="button" class="toolbar-button" @mousedown.prevent="insertLink">Link</button>
<button type="button" class="toolbar-button" @mousedown.prevent @click="clearFormatting">Format löschen</button>
</div>
<div
ref="editor"
class="editor-surface"
:class="{ placeholder: !currentHtml && placeholder }"
:contenteditable="!disabled"
:data-placeholder="placeholder"
:style="{ minHeight }"
@input="handleInput"
@blur="handleBlur"
@paste="handlePaste"
/>
</div>
</template>
<script>
import { sanitizeRichTextHtml } from '../utils/richTextDocumentExport.js';
export default {
name: 'RichTextEditor',
props: {
modelValue: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
minHeight: {
type: String,
default: '180px',
},
},
emits: ['update:modelValue'],
data() {
return {
currentHtml: '',
};
},
watch: {
modelValue: {
immediate: true,
handler(value) {
const sanitized = sanitizeRichTextHtml(value || '');
this.currentHtml = sanitized;
if (this.$refs.editor && this.$refs.editor.innerHTML !== sanitized) {
this.$refs.editor.innerHTML = sanitized;
}
},
},
},
mounted() {
this.syncEditor();
},
methods: {
syncEditor() {
if (!this.$refs.editor) return;
const sanitized = sanitizeRichTextHtml(this.modelValue || '');
this.$refs.editor.innerHTML = sanitized;
this.currentHtml = sanitized;
},
emitCurrentValue() {
const nextHtml = sanitizeRichTextHtml(this.$refs.editor?.innerHTML || '');
this.currentHtml = nextHtml;
this.$emit('update:modelValue', nextHtml);
},
handleInput() {
this.emitCurrentValue();
},
handleBlur() {
this.emitCurrentValue();
},
handlePaste(event) {
if (this.disabled) return;
event.preventDefault();
const html = event.clipboardData?.getData('text/html');
const text = event.clipboardData?.getData('text/plain') || '';
const value = sanitizeRichTextHtml(html || text.replace(/\n/g, '<br>'));
document.execCommand('insertHTML', false, value || text.replace(/\n/g, '<br>'));
this.emitCurrentValue();
},
format(command) {
if (this.disabled) return;
this.$refs.editor?.focus();
document.execCommand(command, false, null);
this.emitCurrentValue();
},
insertLink() {
if (this.disabled) return;
const url = window.prompt('Link einfügen', 'https://');
if (!url) return;
this.$refs.editor?.focus();
document.execCommand('createLink', false, url);
this.emitCurrentValue();
},
clearFormatting() {
if (this.disabled) return;
this.$refs.editor?.focus();
document.execCommand('removeFormat', false, null);
this.emitCurrentValue();
},
},
};
</script>
<style scoped>
.rich-text-editor {
display: grid;
gap: 0.5rem;
}
.editor-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.toolbar-button {
border: 1px solid rgba(24, 70, 54, 0.12);
background: rgba(255, 255, 255, 0.92);
border-radius: 10px;
padding: 0.35rem 0.6rem;
}
.editor-surface {
border: 1px solid rgba(24, 70, 54, 0.16);
border-radius: 14px;
background: rgba(255, 255, 255, 0.96);
padding: 0.9rem;
line-height: 1.55;
overflow: auto;
}
.editor-surface:focus {
outline: 2px solid rgba(47, 122, 95, 0.25);
outline-offset: 1px;
}
.editor-surface.placeholder:empty::before {
content: attr(data-placeholder);
color: var(--text-light);
}
.rich-text-editor.disabled .editor-surface {
background: rgba(245, 247, 250, 0.95);
color: var(--text-secondary);
}
</style>

View File

@@ -28,10 +28,22 @@
<span class="diary-stat-label">{{ $t('diary.participants') }}</span>
<strong class="diary-stat-value">{{ participantCount }}</strong>
</div>
<div class="diary-stat-card">
<span class="diary-stat-label">{{ $t('diary.excusedParticipants') }}</span>
<strong class="diary-stat-value">{{ excusedCount }}</strong>
</div>
<div class="diary-stat-card">
<span class="diary-stat-label">{{ $t('diary.availableParticipants') }}</span>
<strong class="diary-stat-value">{{ availableParticipantCount }}</strong>
</div>
<div class="diary-stat-card">
<span class="diary-stat-label">{{ $t('diary.trainingPlan') }}</span>
<strong class="diary-stat-value">{{ trainingPlanCount }}</strong>
</div>
<div class="diary-stat-card">
<span class="diary-stat-label">{{ $t('diary.activeMembers') }}</span>
<strong class="diary-stat-value">{{ activeMemberCount }}</strong>
</div>
<div class="diary-stat-card">
<span class="diary-stat-label">{{ $t('diary.freeActivities') }}</span>
<strong class="diary-stat-value">{{ activitiesCount }}</strong>
@@ -127,6 +139,9 @@ export default {
diaryStatusText: { type: String, default: '' },
diaryTimeRangeLabel: { type: String, default: '' },
participantCount: { type: Number, default: 0 },
excusedCount: { type: Number, default: 0 },
activeMemberCount: { type: Number, default: 0 },
availableParticipantCount: { type: Number, default: 0 },
trainingPlanCount: { type: Number, default: 0 },
activitiesCount: { type: Number, default: 0 },
trainingStart: { type: String, default: '' },
@@ -214,9 +229,9 @@ export default {
.diary-workspace-stats {
display: grid;
grid-template-columns: repeat(4, minmax(110px, 1fr));
grid-template-columns: repeat(6, minmax(110px, 1fr));
gap: 0.75rem;
width: min(560px, 100%);
width: min(840px, 100%);
}
.diary-stat-card {
@@ -308,7 +323,7 @@ export default {
}
.diary-workspace-stats {
grid-template-columns: repeat(2, minmax(92px, 1fr));
grid-template-columns: repeat(3, minmax(92px, 1fr));
gap: 0.5rem;
width: 100%;
}

View File

@@ -0,0 +1,360 @@
export const CLUB_DATA_MODELS = {
club: {
table: 'clubs',
purpose: 'Vereinsstammdaten und organisatorische Grundeinstellungen für TT-Verein.',
fields: [
'id',
'name',
'short_name',
'legal_name',
'club_number',
'email',
'phone',
'website',
'street',
'postal_code',
'city',
'country_code',
'chairperson_name',
'treasurer_name',
'youth_manager_name',
'creditor_identifier',
'billing_email',
'iban',
'bic',
'fee_rules',
'is_archived',
'archived_at',
'created_at',
'updated_at',
],
},
member: {
table: 'clubmembers',
purpose: 'Mitglied als zentrale Person im Verein mit Stammdaten, Status und Zahlungsbezug.',
fields: [
'id',
'club_id',
'user_id',
'member_number',
'membership_status',
'membership_type',
'joined_on',
'left_on',
'birthdate',
'email',
'phone',
'street',
'postal_code',
'city',
'country_code',
'contribution_group_code',
'needs_sepa_mandate',
'sepa_mandate_reference',
'is_archived',
'archived_at',
'archived_reason',
'created_at',
'updated_at',
],
},
request: {
table: 'club_requests',
purpose: 'Zentraler Eingang für Kontakt-, Probetraining-, Mitgliedschafts- und Sponsoringanfragen.',
fields: [
'id',
'club_id',
'request_type',
'status',
'workflow_stage',
'priority',
'source_system',
'source_reference',
'subject',
'first_name',
'last_name',
'email',
'phone',
'birthdate',
'message',
'assigned_user_id',
'assigned_member_id',
'converted_member_id',
'received_at',
'closed_at',
'created_at',
'updated_at',
],
},
communicationThread: {
table: 'club_communication_threads',
purpose: 'Gesprächsstrang für Einzelnachrichten, Rundschreiben und spätere interne Kommunikation.',
fields: [
'id',
'club_id',
'thread_type',
'subject',
'status',
'created_by_user_id',
'scheduled_at',
'sent_at',
'created_at',
'updated_at',
],
},
distributionGroup: {
table: 'club_distribution_groups',
purpose: 'Wiederverwendbare Verteilergruppen für Kommunikation.',
fields: [
'id',
'club_id',
'name',
'description',
'group_type',
'is_system_group',
'created_at',
'updated_at',
],
},
event: {
table: 'calendar_events',
purpose: 'Vereinstermine für Training, Spiele und Vereinsveranstaltungen mit Fristen und Zuständigkeiten.',
fields: [
'id',
'club_id',
'event_type',
'status',
'title',
'description',
'location',
'start_date',
'end_date',
'registration_deadline',
'organizer_user_id',
'notes',
'created_at',
'updated_at',
'archived_at',
],
},
document: {
table: 'club_documents',
purpose: 'Dokumentenstamm für Satzung, Protokolle, Formulare und Vereinsdokumente mit Versionen, Sichtbarkeit und Belegbezug.',
fields: [
'id',
'club_id',
'document_type',
'title',
'description',
'status',
'current_version_no',
'owner_user_id',
'visibility_scope',
'archived_at',
'created_at',
'updated_at',
],
},
task: {
table: 'club_tasks',
purpose: 'Aufgaben, Wiedervorlagen und Fristen für den Vereinsbetrieb.',
fields: [
'id',
'club_id',
'title',
'task_type',
'description',
'status',
'priority',
'due_at',
'remind_at',
'created_by_user_id',
'assigned_user_id',
'automation_source',
'automation_key',
'related_entity_type',
'related_entity_id',
'source_snapshot',
'created_at',
'updated_at',
'completed_at',
'archived_at',
],
},
sponsor: {
table: 'club_invoice_parties',
purpose: 'Sponsorenbeziehung innerhalb der Rechnungsparteien mit Ansprechpartnern, Verträgen und Laufzeiten.',
fields: [
'id',
'club_id',
'party_type',
'status',
'name',
'contract_reference',
'valid_from',
'valid_to',
'contact_name',
'email',
'phone',
'street',
'postal_code',
'city',
'country_code',
'iban',
'bic',
'tax_identifier',
'notes',
'created_at',
'updated_at',
],
},
feeRule: {
table: 'club_fee_rules',
purpose: 'Beitragssätze inklusive Familienbeiträgen und Ermäßigungen.',
fields: [
'id',
'club_id',
'code',
'name',
'category',
'billing_cycle',
'base_amount_cents',
'currency_code',
'age_from',
'age_to',
'is_family_rule',
'is_reduction_rule',
'valid_from',
'valid_to',
'created_at',
'updated_at',
],
},
sepaMandate: {
table: 'club_sepa_mandates',
purpose: 'SEPA-Mandate für Mitglieder oder Beitragszahler.',
fields: [
'id',
'club_id',
'member_id',
'debtor_name',
'iban',
'bic',
'mandate_reference',
'signed_on',
'valid_from',
'revoked_at',
'status',
'history_note',
'created_at',
'updated_at',
],
},
account: {
table: 'club_accounts',
purpose: 'Vereinskonten als Grundlage für Zahlungswege, SEPA-Einzüge und spätere Finanzprozesse.',
fields: [
'id',
'club_id',
'name',
'account_holder',
'bank_name',
'iban',
'bic',
'account_type',
'usage_type',
'currency_code',
'allow_sepa_collections',
'allow_outgoing_payments',
'is_default',
'status',
'notes',
'sort_order',
'archived_at',
'created_at',
'updated_at',
],
},
paymentClaim: {
table: 'club_payment_claims',
purpose: 'Offene Beitragsforderungen und sonstige Zahlungsansprüche.',
fields: [
'id',
'club_id',
'member_id',
'fee_rule_id',
'claim_type',
'status',
'due_on',
'amount_cents',
'paid_amount_cents',
'currency_code',
'reminder_level',
'last_reminder_at',
'created_at',
'updated_at',
'settled_at',
'last_paid_at',
'archived_at',
],
},
invoice: {
table: 'club_invoices',
purpose: 'Ein- und Ausgangsrechnungen mit Parteien- und Dokumentenbezug.',
fields: [
'id',
'club_id',
'invoice_direction',
'invoice_type',
'status',
'invoice_number',
'external_reference',
'party_id',
'account_id',
'issued_on',
'due_on',
'paid_on',
'net_amount_cents',
'tax_amount_cents',
'gross_amount_cents',
'currency_code',
'description',
'created_at',
'updated_at',
'archived_at',
],
},
historyEntry: {
table: 'club_history_entries',
purpose: 'Änderungs- und Aktivitätsprotokoll für jedes Vereinsmodul.',
fields: [
'id',
'club_id',
'entity_type',
'entity_id',
'action_type',
'actor_user_id',
'actor_member_id',
'old_value_json',
'new_value_json',
'summary',
'created_at',
],
},
roleAssignment: {
table: 'club_user_roles',
purpose: 'Rollenbasierte Berechtigungszuordnung für Verwaltungsnutzer.',
fields: [
'id',
'club_id',
'user_id',
'member_id',
'role_code',
'valid_from',
'valid_to',
'is_primary',
'created_at',
'updated_at',
],
},
};

View File

@@ -0,0 +1,307 @@
export const CLUB_DASHBOARD_SECTIONS = [
{
id: 'action-needed',
title: 'Handlungsbedarf',
cards: [
{
title: 'Neue Anfragen',
accent: 'amber',
items: ['3 neue Probetrainings', '1 Sponsoringanfrage'],
},
{
title: 'Offene Zahlungen',
accent: 'red',
items: ['7 Mitgliedsbeiträge offen', '2 Mahnungen fällig'],
},
{
title: 'Fehlende Daten',
accent: 'blue',
items: ['5 Mitglieder ohne E-Mail', '2 Mitglieder ohne Geburtsdatum', '3 Mitglieder ohne SEPA-Mandat'],
},
{
title: 'Offene Aufgaben',
accent: 'green',
items: ['Vereinsmeisterschaft planen', 'Hallendienst besetzen'],
},
],
},
{
id: 'today-and-week',
title: 'Aktuelle Termine',
cards: [
{
title: 'Heute',
accent: 'green',
items: ['Jugendtraining 17:00 Uhr', 'Vorstandssitzung 19:30 Uhr'],
},
{
title: 'Diese Woche',
accent: 'blue',
items: ['Heimspiel Herren 1', 'Vereinsmeisterschaft Meldeschluss'],
},
],
},
{
id: 'club-status',
title: 'Vereinsstatus',
cards: [
{
title: 'Mitglieder',
value: '87 aktiv',
meta: '+4 dieses Jahr',
accent: 'green',
},
{
title: 'Anfragen',
value: '12 offen',
meta: '4 in Bearbeitung',
accent: 'amber',
},
{
title: 'Finanzen',
value: '93 % bezahlt',
meta: 'Mitgliedsbeiträge',
accent: 'blue',
},
{
title: 'Dokumente',
value: '8 unbearbeitet',
meta: 'Neue oder offene Unterlagen',
accent: 'red',
},
],
},
{
id: 'recent-activity',
title: 'Letzte Aktivitäten',
cards: [
{
title: 'Zuletzt passiert',
accent: 'neutral',
items: ['Mitglied angelegt', 'Rechnung bezahlt', 'Dokument hochgeladen', 'Anfrage beantwortet'],
},
],
},
];
export const CLUB_DASHBOARD_QUICK_LINKS = [
{ to: '/club-tasks', label: 'Aufgaben steuern', icon: '✅', permission: ['tasks', 'read'] },
{ to: '/club-requests', label: 'Anfragen bearbeiten', icon: '📥', permission: ['requests', 'read'] },
{ to: '/members', label: 'Mitglieder öffnen', icon: '👥', permission: ['members', 'read'] },
{ to: '/club-payments', label: 'Zahlungen prüfen', icon: '💶', permission: ['finance_accounts', 'read'] },
{ to: '/club-documents', label: 'Dokumente verwalten', icon: '🗂️', permission: ['settings', 'read'] },
];
export const CLUB_MENU_SECTIONS = [
{
id: 'main',
title: 'Hauptmenü',
items: [
{ to: '/', icon: '🏠', label: 'Dashboard' },
{ to: '/club-requests', icon: '📥', label: 'Anfragen', permission: ['requests', 'read'] },
{ to: '/members', icon: '👥', label: 'Mitglieder', permission: ['members', 'read'] },
{ to: '/club-communication', icon: '💬', label: 'Kommunikation', permission: ['communication', 'read'] },
{ to: '/calendar', icon: '📆', label: 'Termine', permission: ['schedule', 'read'] },
{ to: '/club-documents', icon: '🗂️', label: 'Dokumente', permission: ['settings', 'read'] },
],
},
{
id: 'organisation',
title: 'Organisation',
items: [
{ to: '/club-tasks', icon: '✅', label: 'Aufgaben', permission: ['tasks', 'read'] },
{ to: '/team-management', icon: '🧩', label: 'Mannschaften', permission: ['teams', 'read'] },
{ to: '/club-events', icon: '🎪', label: 'Veranstaltungen', permission: ['schedule', 'read'] },
{ to: '/club-sponsors', icon: '🤝', label: 'Sponsoren', permission: ['settings', 'read'] },
],
},
{
id: 'finance',
title: 'Finanzen',
items: [
{ to: '/club-fees', icon: '💳', label: 'Beiträge', permission: ['finance_invoices', 'write'] },
{ to: '/club-payments', icon: '💶', label: 'Zahlungen', permission: ['finance_accounts', 'write'] },
{ to: '/club-invoices', icon: '🧾', label: 'Rechnungen', permission: ['finance_invoices', 'write'] },
{ to: '/club-accounts', icon: '🏦', label: 'Konten', permission: ['finance_accounts', 'write'] },
],
},
{
id: 'administration',
title: 'Verwaltung',
items: [
{ to: '/club-users', icon: '👤', label: 'Benutzer', permission: ['permissions', 'read'] },
{ to: '/club-roles', icon: '🛡️', label: 'Rollen', permission: ['permissions', 'read'] },
{ to: '/club-history', icon: '🕘', label: 'Historie', permission: ['history', 'read'] },
{ to: '/club-settings', icon: '⚙️', label: 'Einstellungen', capability: 'admin' },
],
},
{
id: 'analysis',
title: 'Auswertung',
items: [
{ to: '/club-statistics', icon: '📊', label: 'Statistiken', permission: ['statistics', 'read'] },
{ to: '/club-reports', icon: '📑', label: 'Berichte', permission: ['statistics', 'read'] },
{ to: '/club-archive', icon: '🗄️', label: 'Archiv', permission: ['archive', 'read'] },
],
},
];
export const CLUB_CONCEPT_ROUTES = [
{
path: '/club-requests',
name: 'club-requests',
title: 'Anfragen',
phase: 'Phase 1',
summary: 'Kontaktanfragen, Probetrainings, Mitgliedschaftsanfragen und Sponsoringanfragen in einem einheitlichen Eingang.',
highlights: ['Kontaktanfragen', 'Probetraining', 'Mitgliedschaftsanfragen', 'Sponsoringanfragen'],
principles: ['Zentrale Eingangsliste statt verteilter E-Mail-Postfächer', 'Bearbeitungsstatus für jeden Vorgang', 'Überführung in Mitglieder, Aufgaben oder Kommunikation'],
},
{
path: '/club-communication',
name: 'club-communication',
title: 'Kommunikation',
phase: 'Phase 1',
summary: 'Kommunikation für Einzelpersonen, Gruppen und Rundschreiben mit klarem Vereinskontext.',
highlights: ['Einzelnachrichten', 'Rundschreiben', 'Verteilergruppen'],
principles: ['Kommunikation direkt aus dem Vereinskontext', 'Nutzbar für Vorstand, Trainer und Verwaltung', 'Später API-fähig für externe Eingaben'],
},
{
path: '/club-documents',
name: 'club-documents',
title: 'Dokumente',
phase: 'Phase 1',
summary: 'Vereinsdokumente, Formulare und Protokolle an einem zentralen Ort statt in verstreuten Ordnern.',
highlights: ['Satzung', 'Protokolle', 'Formulare', 'Vereinsdokumente'],
principles: ['Archiv statt Löschen', 'Berechtigungen pro Dokumententyp', 'Später erweiterbar Richtung DMS'],
},
{
path: '/club-tasks',
name: 'club-tasks',
title: 'Aufgaben',
phase: 'Phase 2',
summary: 'Aufgaben, Wiedervorlagen und Fristen für den Vereinsbetrieb in einem einfachen Arbeitsbereich.',
highlights: ['Aufgabenverwaltung', 'Wiedervorlagen', 'Fristen'],
principles: ['Dashboard-getrieben: Was muss heute erledigt werden?', 'Verknüpfbar mit Anfragen, Veranstaltungen und Finanzen', 'Geeignet für Vorstand und Orga-Teams'],
},
{
path: '/club-training',
name: 'club-training',
title: 'Training',
phase: 'Phase 1',
summary: 'Trainingsbezogene Vereinsorganisation als eigener Bereich innerhalb von TT-Verein.',
highlights: ['Trainingskoordination', 'Abstimmung mit Terminen', 'Verknüpfung zu Mannschaften und Mitgliedern'],
principles: ['Nicht trainerzentriert, sondern vereinsorganisatorisch', 'Saubere Schnittstelle zum Trainings-Tagebuch', 'Fokus auf Vereinsbetrieb'],
},
{
path: '/club-events',
name: 'club-events',
title: 'Veranstaltungen',
phase: 'Phase 1',
summary: 'Planung von Vereinsveranstaltungen neben Training und Spielbetrieb.',
highlights: ['Vereinsveranstaltungen', 'Fristen', 'Verantwortlichkeiten'],
principles: ['Veranstaltungen sind eigene Vereinsobjekte', 'Organisation mit Aufgaben und Dokumenten verbinden', 'Dashboard-relevante Fristen sichtbar machen'],
},
{
path: '/club-sponsors',
name: 'club-sponsors',
title: 'Sponsoren',
phase: 'Phase 2',
summary: 'Sponsorenliste, Ansprechpartner und Vertragsbezug als eigener Organisationsbereich.',
highlights: ['Sponsorenliste', 'Ansprechpartner', 'Verträge'],
principles: ['Sponsoring nicht nur als Anfrage, sondern als laufende Beziehung', 'Verknüpfbar mit Rechnungen und Dokumenten', 'Spätere Entwicklungsauswertung möglich'],
},
{
path: '/club-fees',
name: 'club-fees',
title: 'Beiträge',
phase: 'Phase 2',
summary: 'Beitragssätze, Familienbeiträge und Ermäßigungen für die Vereinsverwaltung.',
highlights: ['Beitragssätze', 'Familienbeiträge', 'Ermäßigungen'],
principles: ['Mitglieder- und Zahlungsbezug aus einer Quelle', 'Grundlage für offene Beiträge und Mahnstufen', 'Keine externe Beitragsliste mehr nötig'],
},
{
path: '/club-payments',
name: 'club-payments',
title: 'Zahlungen',
phase: 'Phase 2',
summary: 'Offene Beiträge, Zahlungseingänge und Mahnstufen für Vorstand und Kassenrolle.',
highlights: ['Offene Beiträge', 'Zahlungseingänge', 'Mahnstufen'],
principles: ['Dashboard zeigt offenen Handlungsbedarf', 'Mitgliederdaten und Beiträge greifen zusammen', 'Basis für spätere SEPA-Workflows'],
},
{
path: '/club-invoices',
name: 'club-invoices',
title: 'Rechnungen',
phase: 'Phase 2',
summary: 'Eingangs- und Ausgangsrechnungen in einem durchgängigen Vereinskontext.',
highlights: ['Hallenmiete', 'Verbandsbeiträge', 'Material', 'Sponsoren und sonstige Forderungen'],
principles: ['Trennung von Einnahmen und Ausgaben', 'Belegbezug zu Dokumenten und Sponsoren', 'Historie und Archiv standardmäßig vorgesehen'],
},
{
path: '/club-accounts',
name: 'club-accounts',
title: 'Konten',
phase: 'Phase 2',
summary: 'Finanzkonten als organisatorische Grundlage für Zahlungen, Rechnungen und spätere SEPA-Prozesse.',
highlights: ['Kontenübersicht', 'Kontobezug für Zahlungen', 'Vorbereitung für SEPA'],
principles: ['Nicht Buchhaltung im Vollsinn, sondern Vereinsorganisation', 'Nachvollziehbare Zuordnung von Zahlungswegen', 'Grundlage für Kassenprozesse'],
},
{
path: '/club-users',
name: 'club-users',
title: 'Benutzer',
phase: 'Phase 1',
summary: 'Benutzerverwaltung für die Personen, die im Verein mit der Plattform arbeiten.',
highlights: ['Vorstand', 'Kassierer', 'Trainer', 'Jugendwart', 'Schriftführer', 'Mitglied'],
principles: ['Nicht jedes Mitglied ist automatisch Verwaltungsnutzer', 'Rollenbasiert statt frei erfundener Einzelrechte', 'Sauber trennbar von Vereinsmitgliedern'],
},
{
path: '/club-roles',
name: 'club-roles',
title: 'Rollen',
phase: 'Phase 1',
summary: 'Rollenbasierte Zugriffslogik für alle Vereinsmodule.',
highlights: ['Vorstand', 'Kassierer', 'Trainer', 'Jugendwart', 'Schriftführer', 'Mitglied'],
principles: ['Jedes Modul prüft Berechtigungen', 'Rollen sind produktzentral, nicht nachträglich angeheftet', 'Grundlage für Historie und API-Fähigkeit'],
},
{
path: '/club-history',
name: 'club-history',
title: 'Historie',
phase: 'Phase 1',
summary: 'Änderungsprotokoll, Benutzerprotokoll und Aktivitäten als durchgängiges Grundprinzip.',
highlights: ['Wer?', 'Wann?', 'Was?', 'Alter Wert', 'Neuer Wert'],
principles: ['Historie überall', 'Archiv statt Löschen', 'Nachvollziehbarkeit für den Vereinsbetrieb'],
},
{
path: '/club-statistics',
name: 'club-statistics',
title: 'Statistiken',
phase: 'Phase 3',
summary: 'Spätere Auswertung zu Mitgliederentwicklung, Altersstruktur, Beitragsentwicklung und Sponsorenentwicklung.',
highlights: ['Mitgliederentwicklung', 'Altersstruktur', 'Beitragsentwicklung', 'Sponsorenentwicklung'],
principles: ['Dashboard vor Statistik', 'Statistiken folgen erst auf belastbare Prozesse', 'Auswertungen bauen auf denselben Grunddaten auf'],
},
{
path: '/club-reports',
name: 'club-reports',
title: 'Berichte',
phase: 'Phase 3',
summary: 'Berichte, Schriftverkehr und spätere PDF-Ausgabe für den Vereinsalltag.',
highlights: ['Briefeditor', 'PDF-Erzeugung', 'Vorlagenverwaltung'],
principles: ['Vorlagen für wiederkehrende Vereinsprozesse', 'Geeignet für Aufnahme, Mahnung und Einladungen', 'Sauber mit Dokumenten und Historie verzahnt'],
},
{
path: '/club-archive',
name: 'club-archive',
title: 'Archiv',
phase: 'Phase 3',
summary: 'Archivierte Mitglieder, historische Dokumente und alte Rechnungen als eigene Auswertungsebene.',
highlights: ['Ehemalige Mitglieder', 'Historische Dokumente', 'Alte Rechnungen'],
principles: ['Archiv statt Löschen ist Standard', 'Ruhige Trennung von aktivem Bestand und Historie', 'Verknüpfbar mit Dokumenten und Historie'],
},
];
export function getClubConceptRouteByPath(path) {
return CLUB_CONCEPT_ROUTES.find((route) => route.path === path) || null;
}

View File

@@ -0,0 +1,174 @@
import { CLUB_MENU_SECTIONS } from './clubWorkspace.js';
export const PRODUCT_TRAINER = 'trainer';
export const PRODUCT_CLUB = 'club';
export const PRODUCT_PLAYER = 'player';
export const FULL_APP_PRODUCTS = [PRODUCT_TRAINER, PRODUCT_CLUB];
const PRODUCT_HOSTS = {
[PRODUCT_TRAINER]: ['tt-tagebuch.de', 'www.tt-tagebuch.de', 'trainer.localhost'],
[PRODUCT_CLUB]: ['tt-verein.de', 'www.tt-verein.de', 'club.localhost'],
[PRODUCT_PLAYER]: ['mein-tt.de', 'www.mein-tt.de', 'player.localhost'],
};
const PRODUCT_CONFIGS = {
[PRODUCT_TRAINER]: {
id: PRODUCT_TRAINER,
brandName: 'Trainings-Tagebuch',
appName: 'tt-tagebuch.de',
defaultHomeRoute: '/',
canonicalUrl: import.meta.env.VITE_CANONICAL_TRAINER_URL || 'https://tt-tagebuch.de',
seo: {
siteName: 'Trainings-Tagebuch',
defaultTitle: 'Trainings-Tagebuch Tischtennis-Trainingsverwaltung',
defaultDescription:
'Trainer ist die bisherige vollständige Tischtennis-Anwendung für Training, Organisation, Turniere, Teams und Vereinsalltag.',
imagePath: '/android-chrome-512x512.png',
},
},
[PRODUCT_CLUB]: {
id: PRODUCT_CLUB,
brandName: 'TT Verein',
appName: 'tt-verein.de',
defaultHomeRoute: '/',
canonicalUrl: import.meta.env.VITE_CANONICAL_CLUB_URL || 'https://tt-verein.de',
seo: {
siteName: 'TT Verein',
defaultTitle: 'TT Verein Vereinsverwaltung für Tischtennis',
defaultDescription:
'TT Verein ist die zentrale Arbeitsplattform für kleine und mittlere Tischtennisvereine mit Fokus auf Mitglieder, Kommunikation, Dokumente, Termine und Vereinsbetrieb.',
imagePath: '/android-chrome-512x512.png',
},
},
[PRODUCT_PLAYER]: {
id: PRODUCT_PLAYER,
brandName: 'Mein TT',
appName: 'mein-tt.de',
defaultHomeRoute: '/',
canonicalUrl: import.meta.env.VITE_CANONICAL_PLAYER_URL || 'https://mein-tt.de',
seo: {
siteName: 'Mein TT',
defaultTitle: 'Mein TT Tischtennis für Spieler',
defaultDescription:
'Mein TT ist die persönliche Tischtennisoberfläche für Spieler mit Kalender, Kontoverknüpfungen, Bestellungen und persönlichen Einstellungen.',
imagePath: '/android-chrome-512x512.png',
},
},
};
export const SIDEBAR_NAVIGATION = {
[PRODUCT_TRAINER]: [
{
id: 'daily-business',
titleKey: 'navigation.dailyBusiness',
items: [
{ to: '/members', icon: '👥', labelKey: 'navigation.members', permission: ['members', 'read'] },
{ to: '/diary', icon: '📝', labelKey: 'navigation.diary', permission: ['diary', 'read'] },
{
to: '/calendar',
icon: '📆',
label: 'Kalender',
anyPermission: [
['diary', 'read'],
['schedule', 'read'],
['tournaments', 'read'],
],
},
{ to: '/pending-approvals', icon: '⏳', labelKey: 'navigation.approvals', capability: 'approvals' },
{ to: '/training-stats', icon: '📊', labelKey: 'navigation.statistics', permission: ['statistics', 'read'] },
],
},
{
id: 'competitions',
titleKey: 'navigation.competitions',
items: [
{ to: '/tournaments', icon: '🏆', labelKey: 'navigation.clubTournaments', permission: ['tournaments', 'read'] },
{ to: '/tournament-participations', icon: '📋', labelKey: 'navigation.tournamentParticipations', permission: ['tournaments', 'read'] },
{ to: '/schedule', icon: '📅', labelKey: 'navigation.schedule', permission: ['schedule', 'read'] },
{ to: '/friendly-matches', icon: '🤝', label: 'Freundschaftsspiele', permission: ['schedule', 'read'] },
],
},
{
id: 'settings',
titleKey: 'navigation.settings',
items: [
{ to: '/club-settings', icon: '🏛️', labelKey: 'navigation.clubSettings', capability: 'admin' },
{ to: '/predefined-activities', icon: '🎯', labelKey: 'navigation.predefinedActivities', permission: ['predefined_activities', 'read'] },
{ to: '/team-management', icon: '🧩', labelKey: 'navigation.teamManagement', permission: ['teams', 'read'] },
{ to: '/billing', icon: '🧾', labelKey: 'navigation.billing', permission: ['members', 'read'] },
],
},
],
[PRODUCT_CLUB]: CLUB_MENU_SECTIONS,
[PRODUCT_PLAYER]: [
{
id: 'player-area',
title: 'Mein Bereich',
items: [
{ to: '/calendar', icon: '📆', label: 'Kalender' },
{ to: '/mytischtennis-account', icon: '🔗', labelKey: 'navigation.myTischtennisAccount' },
{ to: '/clicktt-account', icon: '🏓', labelKey: 'navigation.clickTtAccount' },
{ to: '/orders', icon: '📦', labelKey: 'navigation.orders' },
{ to: '/personal-settings', icon: '⚙️', labelKey: 'navigation.personalSettings' },
],
},
],
};
export function normalizeProduct(product) {
if (product === PRODUCT_PLAYER) return PRODUCT_PLAYER;
if (product === PRODUCT_CLUB) return PRODUCT_CLUB;
return PRODUCT_TRAINER;
}
export function resolveProductFromHostname(hostname = '') {
const normalizedHostname = String(hostname || '').toLowerCase();
if (PRODUCT_HOSTS[PRODUCT_TRAINER].includes(normalizedHostname)) {
return PRODUCT_TRAINER;
}
if (PRODUCT_HOSTS[PRODUCT_PLAYER].includes(normalizedHostname)) {
return PRODUCT_PLAYER;
}
if (PRODUCT_HOSTS[PRODUCT_CLUB].includes(normalizedHostname)) {
return PRODUCT_CLUB;
}
return PRODUCT_TRAINER;
}
export function resolveCurrentProduct() {
const override = normalizeProduct(import.meta.env.VITE_APP_PRODUCT);
if (import.meta.env.VITE_APP_PRODUCT) {
return override;
}
if (typeof window === 'undefined') {
return PRODUCT_TRAINER;
}
return resolveProductFromHostname(window.location.hostname);
}
export function getProductConfig(product = resolveCurrentProduct()) {
return PRODUCT_CONFIGS[normalizeProduct(product)];
}
export function getDefaultHomeRoute(product = resolveCurrentProduct()) {
return getProductConfig(product).defaultHomeRoute;
}
export function isProductRouteAllowed(product, routeProducts = []) {
if (!Array.isArray(routeProducts) || routeProducts.length === 0) {
return true;
}
return routeProducts.includes(normalizeProduct(product));
}
export function getCurrentBrandName() {
return getProductConfig(resolveCurrentProduct()).brandName;
}

View File

@@ -903,6 +903,9 @@
"durationExampleShort": "z.B. 2x7",
"showImage": "Bild/Zeichnung anzeigen",
"participants": "Teilnehmer",
"excusedParticipants": "Entschuldigt",
"availableParticipants": "Anwesend möglich",
"activeMembers": "Aktive Mitglieder",
"searchParticipants": "Teilnehmer suchen",
"filterAll": "Alle",
"filterPresent": "Anwesend",

View File

@@ -652,6 +652,9 @@
"durationExampleShort": "z.B. 2x7",
"showImage": "Bild/Zeichnung anzeigen",
"participants": "Teilnehmer",
"excusedParticipants": "Entschuldigt",
"availableParticipants": "Anwesend möglich",
"activeMembers": "Aktive Mitglieder",
"searchParticipants": "Teilnehmer suchen",
"filterAll": "Alle",
"filterPresent": "Anwesend",

View File

@@ -339,6 +339,24 @@
"noGroupsAssigned": "Keine Gruppen zugeordnet",
"noGroupsAvailable": "Keine Gruppen verfügbar",
"addGroup": "Gruppe hinzufügen...",
"bankAccountSection": "Bankkonto / SEPA",
"bankAccountLoading": "Bankdaten werden geladen...",
"accountHolder": "Kontoinhaber",
"iban": "IBAN",
"bic": "BIC",
"mandateReference": "Mandatsreferenz",
"signedOn": "Unterschrieben am",
"validFrom": "Gültig ab",
"bankAccountStatus": "Status",
"bankAccountStatusActive": "Aktiv",
"bankAccountStatusPending": "Ausstehend",
"bankAccountStatusRevoked": "Widerrufen",
"bankAccountNote": "Hinweis",
"saveBankAccount": "Bankkonto speichern",
"bankAccountSaved": "Bankkonto erfolgreich gespeichert.",
"bankAccountLoadError": "Bankkonto konnte nicht geladen werden.",
"bankAccountSaveError": "Bankkonto konnte nicht gespeichert werden.",
"bankAccountMissingAfterSave": "Das Bankkonto wurde nach dem Speichern nicht wiedergefunden.",
"remove": "Entfernen",
"image": "Bild",
"selectFile": "Datei auswählen",
@@ -723,6 +741,9 @@
"durationExampleShort": "z.B. 2x7",
"showImage": "Bild/Zeichnung anzeigen",
"participants": "Teilnehmer",
"excusedParticipants": "Entschuldigt",
"availableParticipants": "Anwesend möglich",
"activeMembers": "Aktive Mitglieder",
"searchParticipants": "Teilnehmer suchen",
"filterAll": "Alle",
"filterPresent": "Anwesend",

View File

@@ -333,6 +333,24 @@
"noGroupsAssigned": "No groups assigned",
"noGroupsAvailable": "No groups available",
"addGroup": "Add group...",
"bankAccountSection": "Bank account / SEPA",
"bankAccountLoading": "Loading bank details...",
"accountHolder": "Account holder",
"iban": "IBAN",
"bic": "BIC",
"mandateReference": "Mandate reference",
"signedOn": "Signed on",
"validFrom": "Valid from",
"bankAccountStatus": "Status",
"bankAccountStatusActive": "Active",
"bankAccountStatusPending": "Pending",
"bankAccountStatusRevoked": "Revoked",
"bankAccountNote": "Note",
"saveBankAccount": "Save bank account",
"bankAccountSaved": "Bank account saved successfully.",
"bankAccountLoadError": "Bank account could not be loaded.",
"bankAccountSaveError": "Bank account could not be saved.",
"bankAccountMissingAfterSave": "The bank account could not be found again after saving.",
"remove": "Remove",
"image": "Image",
"selectFile": "Select file",

View File

@@ -714,6 +714,9 @@
"durationExampleShort": "例如2x7",
"showImage": "显示图片/图示",
"participants": "参与者",
"excusedParticipants": "已请假",
"availableParticipants": "可到场",
"activeMembers": "活跃成员",
"searchParticipants": "搜索参与者",
"filterAll": "全部",
"filterPresent": "出席",

View File

@@ -1,6 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router';
import { applySeoForPath } from './utils/seo.js';
import { safeSessionStorage } from './utils/storage.js';
import { safeLocalStorage, safeSessionStorage } from './utils/storage.js';
import { getDefaultHomeRoute, isProductRouteAllowed, resolveCurrentProduct } from './config/products.js';
import { CLUB_CONCEPT_ROUTES } from './config/clubWorkspace.js';
const Register = () => import('./views/Register.vue');
const Login = () => import('./views/Login.vue');
@@ -34,47 +36,182 @@ const MemberTransferSettingsView = () => import('./views/MemberTransferSettingsV
const PersonalSettings = () => import('./views/PersonalSettings.vue');
const OrdersView = () => import('./views/OrdersView.vue');
const BillingView = () => import('./views/BillingView.vue');
const ClubRequestsView = () => import('./views/ClubRequestsView.vue');
const ClubTasksView = () => import('./views/ClubTasksView.vue');
const ClubHistoryView = () => import('./views/ClubHistoryView.vue');
const ClubStatisticsView = () => import('./views/ClubStatisticsView.vue');
const ClubArchiveView = () => import('./views/ClubArchiveView.vue');
const ClubAccountsView = () => import('./views/ClubAccountsView.vue');
const ClubInvoicesView = () => import('./views/ClubInvoicesView.vue');
const ClubCommunicationView = () => import('./views/ClubCommunicationView.vue');
const ClubOperationsWorkspaceView = () => import('./views/ClubOperationsWorkspaceView.vue');
const ClubConceptModuleView = () => import('./views/ClubConceptModuleView.vue');
const Impressum = () => import('./views/Impressum.vue');
const Datenschutz = () => import('./views/Datenschutz.vue');
const KontoLoeschen = () => import('./views/KontoLoeschen.vue');
function withMeta(meta = {}) {
return meta;
}
function getStoredCurrentClubPermissions() {
const currentClub = safeSessionStorage.getItem('currentClub');
if (!currentClub) {
return null;
}
try {
const permissionMap = JSON.parse(safeLocalStorage.getItem('clubPermissions') || '{}');
return permissionMap[currentClub] || null;
} catch (_error) {
return null;
}
}
function hasRoutePermission(resource, action) {
const permissions = getStoredCurrentClubPermissions();
if (!permissions) {
return false;
}
if (permissions.isOwner) {
return true;
}
if (resource === 'mytischtennis') {
return true;
}
return permissions.permissions?.[resource]?.[action] === true;
}
function hasRouteCapability(capability) {
const permissions = getStoredCurrentClubPermissions();
if (!permissions) {
return false;
}
if (permissions.isOwner || permissions.isAdmin || permissions.role === 'admin') {
return true;
}
if (capability === 'approvals') {
return hasRoutePermission('approvals', 'read');
}
if (capability === 'admin') {
return false;
}
return true;
}
function isRouteAuthorized(to) {
const protectedRules = to.matched
.map((record) => record.meta || {})
.filter((meta) => meta.permission || meta.capability || Array.isArray(meta.anyPermission));
if (protectedRules.length === 0) {
return true;
}
return protectedRules.every((meta) => {
if (meta.capability && !hasRouteCapability(meta.capability)) {
return false;
}
if (meta.permission) {
const [resource, action] = meta.permission;
if (!hasRoutePermission(resource, action)) {
return false;
}
}
if (Array.isArray(meta.anyPermission) && !meta.anyPermission.some(([resource, action]) => hasRoutePermission(resource, action))) {
return false;
}
return true;
});
}
const trainerOnly = ['trainer'];
const clubOnly = ['club'];
const fullAppProducts = ['trainer', 'club'];
const allProducts = ['trainer', 'club', 'player'];
const conceptRoutes = CLUB_CONCEPT_ROUTES
.filter((route) => route.path !== '/club-requests')
.filter((route) => route.path !== '/club-tasks')
.filter((route) => route.path !== '/club-users')
.filter((route) => route.path !== '/club-roles')
.filter((route) => route.path !== '/club-history')
.filter((route) => route.path !== '/club-statistics')
.filter((route) => route.path !== '/club-archive')
.filter((route) => route.path !== '/club-accounts')
.filter((route) => route.path !== '/club-invoices')
.filter((route) => route.path !== '/club-communication')
.filter((route) => route.path !== '/club-documents')
.filter((route) => route.path !== '/club-events')
.filter((route) => route.path !== '/club-sponsors')
.filter((route) => route.path !== '/club-fees')
.filter((route) => route.path !== '/club-payments')
.filter((route) => route.path !== '/club-reports')
.map((route) => ({
path: route.path,
name: route.name,
component: ClubConceptModuleView,
meta: withMeta({ products: clubOnly, moduleMeta: route }),
}));
const routes = [
{ path: '/register', name: 'register', component: Register, meta: { public: true } },
{ path: '/login', name: 'login', component: Login, meta: { public: true } },
{ path: '/activate/:activationCode', name: 'activate', component: Activate, meta: { public: true } },
{ path: '/forgot-password', name: 'forgot-password', component: ForgotPassword, meta: { public: true } },
{ path: '/reset-password/:token', name: 'reset-password', component: ResetPassword, meta: { public: true } },
{ path: '/', name: 'home', component: Home, meta: { public: true } },
{ path: '/vereinssoftware-tischtennis', name: 'club-software-seo', component: TableTennisClubSoftware, meta: { public: true } },
{ path: '/mitgliederverwaltung-verein', name: 'member-management-seo', component: ClubMemberManagementPage, meta: { public: true } },
{ path: '/trainingsplanung-tischtennis', name: 'training-planning-seo', component: TrainingPlanningPage, meta: { public: true } },
{ path: '/turniersoftware-tischtennis', name: 'tournament-software-seo', component: TableTennisTournamentSoftwarePage, meta: { public: true } },
{ path: '/createclub', name: 'create-club', component: CreateClub },
{ path: '/showclub/:clubId', name: 'show-club', component: ClubView },
{ path: '/members', name: 'members', component: MembersView },
{ path: '/diary', name: 'diary', component: DiaryView },
{ path: '/calendar', name: 'calendar', component: CalendarView },
{ path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView},
{ path: '/schedule', name: 'schedule', component: ScheduleView},
{ path: '/friendly-matches', name: 'friendly-matches', component: ScheduleView, props: { friendlyOnly: true } },
{ path: '/tournaments', name: 'tournaments', component: TournamentsView },
{ path: '/tournament-participations', name: 'tournament-participations', component: OfficialTournaments },
{ path: '/training-stats', name: 'training-stats', component: TrainingStatsView },
{ path: '/club-settings', name: 'club-settings', component: ClubSettings },
{ path: '/predefined-activities', name: 'predefined-activities', component: PredefinedActivities },
{ path: '/mytischtennis-account', name: 'mytischtennis-account', component: MyTischtennisAccount },
{ path: '/clicktt-account', name: 'clicktt-account', component: ClickTtAccount },
{ path: '/team-management', name: 'team-management', component: TeamManagementView },
{ path: '/permissions', name: 'permissions', component: PermissionsView },
{ path: '/logs', name: 'logs', component: LogsView },
{ path: '/clicktt', name: 'clicktt', component: ClickTtView },
{ path: '/member-transfer-settings', name: 'member-transfer-settings', component: MemberTransferSettingsView },
{ path: '/personal-settings', name: 'personal-settings', component: PersonalSettings },
{ path: '/orders', name: 'orders', component: OrdersView },
{ path: '/billing', name: 'billing', component: BillingView },
{ path: '/impressum', name: 'impressum', component: Impressum, meta: { public: true } },
{ path: '/datenschutz', name: 'datenschutz', component: Datenschutz, meta: { public: true } },
{ path: '/konto-loeschen', name: 'konto-loeschen', component: KontoLoeschen, meta: { public: true } },
{ path: '/register', name: 'register', component: Register, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/login', name: 'login', component: Login, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/activate/:activationCode', name: 'activate', component: Activate, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/forgot-password', name: 'forgot-password', component: ForgotPassword, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/reset-password/:token', name: 'reset-password', component: ResetPassword, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/', name: 'home', component: Home, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/vereinssoftware-tischtennis', name: 'club-software-seo', component: TableTennisClubSoftware, meta: withMeta({ public: true, products: fullAppProducts }) },
{ path: '/mitgliederverwaltung-verein', name: 'member-management-seo', component: ClubMemberManagementPage, meta: withMeta({ public: true, products: fullAppProducts }) },
{ path: '/trainingsplanung-tischtennis', name: 'training-planning-seo', component: TrainingPlanningPage, meta: withMeta({ public: true, products: fullAppProducts }) },
{ path: '/turniersoftware-tischtennis', name: 'tournament-software-seo', component: TableTennisTournamentSoftwarePage, meta: withMeta({ public: true, products: fullAppProducts }) },
{ path: '/createclub', name: 'create-club', component: CreateClub, meta: withMeta({ products: fullAppProducts }) },
{ path: '/showclub/:clubId', name: 'show-club', component: ClubView, meta: withMeta({ products: fullAppProducts }) },
{ path: '/members', name: 'members', component: MembersView, meta: withMeta({ products: fullAppProducts, permission: ['members', 'read'] }) },
{ path: '/diary', name: 'diary', component: DiaryView, meta: withMeta({ products: trainerOnly }) },
{ path: '/calendar', name: 'calendar', component: CalendarView, meta: withMeta({ products: allProducts, anyPermission: [['diary', 'read'], ['schedule', 'read'], ['tournaments', 'read']] }) },
{ path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView, meta: withMeta({ products: fullAppProducts, capability: 'approvals' }) },
{ path: '/schedule', name: 'schedule', component: ScheduleView, meta: withMeta({ products: fullAppProducts, permission: ['schedule', 'read'] }) },
{ path: '/friendly-matches', name: 'friendly-matches', component: ScheduleView, props: { friendlyOnly: true }, meta: withMeta({ products: fullAppProducts }) },
{ path: '/tournaments', name: 'tournaments', component: TournamentsView, meta: withMeta({ products: fullAppProducts, permission: ['tournaments', 'read'] }) },
{ path: '/tournament-participations', name: 'tournament-participations', component: OfficialTournaments, meta: withMeta({ products: fullAppProducts, permission: ['tournaments', 'read'] }) },
{ path: '/training-stats', name: 'training-stats', component: TrainingStatsView, meta: withMeta({ products: fullAppProducts, permission: ['statistics', 'read'] }) },
{ path: '/club-settings', name: 'club-settings', component: ClubSettings, meta: withMeta({ products: fullAppProducts, capability: 'admin' }) },
{ path: '/predefined-activities', name: 'predefined-activities', component: PredefinedActivities, meta: withMeta({ products: fullAppProducts, permission: ['predefined_activities', 'read'] }) },
{ path: '/mytischtennis-account', name: 'mytischtennis-account', component: MyTischtennisAccount, meta: withMeta({ products: allProducts }) },
{ path: '/clicktt-account', name: 'clicktt-account', component: ClickTtAccount, meta: withMeta({ products: allProducts }) },
{ path: '/team-management', name: 'team-management', component: TeamManagementView, meta: withMeta({ products: fullAppProducts, permission: ['teams', 'read'] }) },
{ path: '/permissions', name: 'permissions', component: PermissionsView, meta: withMeta({ products: fullAppProducts, permission: ['permissions', 'read'] }) },
{ path: '/club-users', name: 'club-users', component: PermissionsView, props: { viewMode: 'users' }, meta: withMeta({ products: clubOnly, permission: ['permissions', 'read'] }) },
{ path: '/club-roles', name: 'club-roles', component: PermissionsView, props: { viewMode: 'roles' }, meta: withMeta({ products: clubOnly, permission: ['permissions', 'read'] }) },
{ path: '/logs', name: 'logs', component: LogsView, meta: withMeta({ products: fullAppProducts }) },
{ path: '/clicktt', name: 'clicktt', component: ClickTtView, meta: withMeta({ products: fullAppProducts }) },
{ path: '/member-transfer-settings', name: 'member-transfer-settings', component: MemberTransferSettingsView, meta: withMeta({ products: fullAppProducts }) },
{ path: '/personal-settings', name: 'personal-settings', component: PersonalSettings, meta: withMeta({ products: allProducts }) },
{ path: '/orders', name: 'orders', component: OrdersView, meta: withMeta({ products: allProducts }) },
{ path: '/billing', name: 'billing', component: BillingView, meta: withMeta({ products: trainerOnly }) },
{ path: '/club-requests', name: 'club-requests', component: ClubRequestsView, meta: withMeta({ products: clubOnly, permission: ['requests', 'read'] }) },
{ path: '/club-tasks', name: 'club-tasks', component: ClubTasksView, meta: withMeta({ products: clubOnly, permission: ['tasks', 'read'] }) },
{ path: '/club-history', name: 'club-history', component: ClubHistoryView, meta: withMeta({ products: clubOnly, permission: ['history', 'read'] }) },
{ path: '/club-statistics', name: 'club-statistics', component: ClubStatisticsView, meta: withMeta({ products: clubOnly, permission: ['statistics', 'read'] }) },
{ path: '/club-archive', name: 'club-archive', component: ClubArchiveView, meta: withMeta({ products: clubOnly, permission: ['archive', 'read'] }) },
{ path: '/club-accounts', name: 'club-accounts', component: ClubAccountsView, meta: withMeta({ products: clubOnly, permission: ['finance_accounts', 'read'] }) },
{ path: '/club-invoices', name: 'club-invoices', component: ClubInvoicesView, meta: withMeta({ products: clubOnly, permission: ['finance_invoices', 'read'] }) },
{ path: '/club-communication', name: 'club-communication', component: ClubCommunicationView, meta: withMeta({ products: clubOnly, permission: ['communication', 'read'] }) },
{ path: '/club-documents', name: 'club-documents', component: ClubOperationsWorkspaceView, props: { moduleKey: 'documents' }, meta: withMeta({ products: clubOnly, permission: ['settings', 'read'] }) },
{ path: '/club-events', name: 'club-events', component: ClubOperationsWorkspaceView, props: { moduleKey: 'events' }, meta: withMeta({ products: clubOnly, permission: ['schedule', 'read'] }) },
{ path: '/club-sponsors', name: 'club-sponsors', component: ClubOperationsWorkspaceView, props: { moduleKey: 'sponsors' }, meta: withMeta({ products: clubOnly, permission: ['settings', 'read'] }) },
{ path: '/club-fees', name: 'club-fees', component: ClubOperationsWorkspaceView, props: { moduleKey: 'fees' }, meta: withMeta({ products: clubOnly, permission: ['finance_invoices', 'write'] }) },
{ path: '/club-payments', name: 'club-payments', component: ClubOperationsWorkspaceView, props: { moduleKey: 'payments' }, meta: withMeta({ products: clubOnly, permission: ['finance_accounts', 'read'] }) },
{ path: '/club-reports', name: 'club-reports', component: ClubOperationsWorkspaceView, props: { moduleKey: 'reports' }, meta: withMeta({ products: clubOnly, permission: ['statistics', 'read'] }) },
...conceptRoutes,
{ path: '/impressum', name: 'impressum', component: Impressum, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/datenschutz', name: 'datenschutz', component: Datenschutz, meta: withMeta({ public: true, products: allProducts }) },
{ path: '/konto-loeschen', name: 'konto-loeschen', component: KontoLoeschen, meta: withMeta({ public: true, products: allProducts }) },
];
const router = createRouter({
@@ -83,8 +220,16 @@ const router = createRouter({
});
router.beforeEach((to, from, next) => {
const currentProduct = resolveCurrentProduct();
const defaultHomeRoute = getDefaultHomeRoute(currentProduct);
const isAuthenticated = Boolean(safeSessionStorage.getItem('token'));
const isPublicRoute = to.matched.some((record) => record.meta?.public);
const routeProducts = to.matched.flatMap((record) => record.meta?.products || []);
if (!isProductRouteAllowed(currentProduct, routeProducts)) {
next(defaultHomeRoute);
return;
}
if (!isAuthenticated && !isPublicRoute) {
next({
@@ -95,7 +240,12 @@ router.beforeEach((to, from, next) => {
}
if (isAuthenticated && (to.path === '/login' || to.path === '/register')) {
next('/');
next(defaultHomeRoute);
return;
}
if (isAuthenticated && !isPublicRoute && !isRouteAuthorized(to)) {
next(defaultHomeRoute);
return;
}

View File

@@ -3,6 +3,10 @@ import router from './router.js';
import apiClient from './apiClient.js';
import { safeSessionStorage, safeLocalStorage } from './utils/storage.js';
import i18n from './i18n';
import { getProductConfig, resolveCurrentProduct } from './config/products.js';
const initialProduct = resolveCurrentProduct();
const initialProductConfig = getProductConfig(initialProduct);
const store = createStore({
state: {
@@ -43,6 +47,9 @@ const store = createStore({
// Browser-Sprache wird in i18n/index.js erkannt
return null;
})(),
appProduct: initialProduct,
appBrand: initialProductConfig.brandName,
defaultHomeRoute: initialProductConfig.defaultHomeRoute,
},
mutations: {
setToken(state, token) {
@@ -97,6 +104,12 @@ const store = createStore({
state.language = language;
safeLocalStorage.setItem('userLanguage', language);
},
setAppProduct(state, product) {
const config = getProductConfig(product);
state.appProduct = config.id;
state.appBrand = config.brandName;
state.defaultHomeRoute = config.defaultHomeRoute;
},
clearToken(state) {
state.token = null;
safeSessionStorage.removeItem('token');
@@ -200,7 +213,9 @@ const store = createStore({
const data = response.data || {};
const normalized = {
role: data.role ?? 'member',
roles: Array.isArray(data.roles) ? data.roles : [],
isOwner: data.isOwner ?? false,
isAdmin: data.isAdmin ?? (data.role === 'admin'),
permissions: data.permissions ?? {}
};
commit('setPermissions', { clubId, permissions: normalized });
@@ -211,7 +226,9 @@ const store = createStore({
clubId,
permissions: {
role: 'member',
roles: [],
isOwner: false,
isAdmin: false,
permissions: {}
}
});
@@ -256,6 +273,9 @@ const store = createStore({
clubs: state => state.clubs,
sidebarCollapsed: state => state.sidebarCollapsed,
language: state => state.language,
appProduct: state => state.appProduct,
appBrand: state => state.appBrand,
defaultHomeRoute: state => state.defaultHomeRoute,
currentClubName: state => {
const club = state.clubs.find(club => club.id === parseInt(state.currentClub));
return club ? club.name : '';
@@ -284,7 +304,18 @@ const store = createStore({
userRole: state => {
if (!state.currentClub) return null;
const perms = state.permissions[state.currentClub];
return perms?.role || null; // null wenn nicht geladen, nicht 'member'
if (perms?.isAdmin) return 'admin';
return perms?.role || null;
},
userRoles: state => {
if (!state.currentClub) return [];
const perms = state.permissions[state.currentClub];
return Array.isArray(perms?.roles) ? perms.roles : [];
},
isAdminRole: state => {
if (!state.currentClub) return false;
const perms = state.permissions[state.currentClub];
return perms?.isAdmin || false;
},
// Dialog-Getters
dialogs: state => state.dialogs,

Some files were not shown because too many files have changed in this diff Show More