implementierung der ersten schritte eine komplett-suite

This commit is contained in:
Torsten Schulz (local)
2026-06-19 15:47:32 +02:00
parent 111b37b287
commit 542fae089c
62 changed files with 11924 additions and 669 deletions

View File

@@ -0,0 +1,60 @@
import clubAccountService from '../services/clubAccountService.js';
class ClubAccountController {
async listClubAccounts(req, res) {
try {
const { clubId } = req.params;
const accounts = await clubAccountService.listClubAccounts(Number(clubId));
res.json({ accounts });
} 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.' });
}
}
}
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,557 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import {
CalendarEvent,
ClubPaymentClaim,
ClubRequest,
ClubSepaMandate,
ClubTask,
Match,
Member,
TrainingGroup,
} from '../models/index.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',
}[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',
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 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 [
requests,
tasks,
members,
mandates,
paymentClaims,
upcomingEvents,
trainingGroups,
upcomingMatches,
] = 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'],
})),
loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
})),
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,
})),
]);
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 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',
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',
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: reminderCount > 0 ? `${reminderCount} mit Mahnstufe` : 'Keine Mahnungen aktiv',
to: '/club-tasks',
items: paymentClaims.slice(0, 3).map((claim) => {
const amount = `${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'}`;
return createDashboardItem(
`${amount} fällig am ${claim.dueOn}`,
claim.memberId ? buildMemberRoute(claim.memberId, 'active') : '/club-tasks'
);
}),
},
{
title: 'Fehlende Daten',
value: `${missingFields.email + missingFields.birthDate + missingMandateCount}`,
to: '/members',
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',
items: [
createDashboardItem(`${automatedOpenTasks.length} automatisch erzeugte Schritte`, '/club-tasks'),
...visibleDashboardTasks.slice(0, 3).map((task) => taskDashboardItem(task, task.title)),
],
},
],
},
{
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 === '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,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,274 @@
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,
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

@@ -28,7 +28,7 @@ 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,
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: clubId } = req.params;
const { authcode: userToken } = req.headers;
@@ -47,6 +47,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 +354,8 @@ export {
getClubMembers,
getWaitingApprovals,
setClubMembers,
getMemberSepaMandate,
saveMemberSepaMandate,
getMemberPlayInterests,
setMemberPlayInterest,
uploadMemberImage,

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,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,78 @@
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'
},
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'
},
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

@@ -70,6 +70,15 @@ 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 ClubTask from './ClubTask.js';
import ClubTaskSuppression from './ClubTaskSuppression.js';
import ClubRole from './ClubRole.js';
import ClubUserRole from './ClubUserRole.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -172,6 +181,10 @@ 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' });
@@ -447,6 +460,50 @@ 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 });
Club.hasMany(ClubAccount, { foreignKey: 'clubId', as: 'accounts' });
ClubAccount.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
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 });
export {
User,
Log,
@@ -517,4 +574,13 @@ export {
ClubDisabledPresetGroup,
TrainingTime,
ClubVenue,
ClubRequest,
ClubRequestNote,
ClubSepaMandate,
ClubPaymentClaim,
ClubAccount,
ClubTask,
ClubTaskSuppression,
ClubRole,
ClubUserRole,
};

View File

@@ -0,0 +1,16 @@
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('members', 'read'), clubAccountController.listClubAccounts);
router.post('/:clubId', authorize('members', 'write'), clubAccountController.createClubAccount);
router.put('/:clubId/:accountId', authorize('members', 'write'), clubAccountController.updateClubAccount);
router.patch('/:clubId/:accountId/status', authorize('members', 'write'), clubAccountController.updateClubAccountStatus);
router.delete('/:clubId/:accountId', authorize('members', 'write'), clubAccountController.deleteClubAccount);
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('settings', 'read'), clubArchiveController.getClubArchive);
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,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('members', 'read'), listClubRequests);
router.post('/:clubId', authenticate, authorize('members', 'write'), createClubRequest);
router.put('/:clubId/:requestId', authenticate, authorize('members', 'write'), updateClubRequest);
router.patch('/:clubId/:requestId/status', authenticate, authorize('members', 'write'), updateClubRequestStatus);
router.post('/:clubId/:requestId/notes', authenticate, authorize('members', 'write'), addClubRequestNote);
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('members', 'read'), listClubTasks);
router.post('/:clubId', authenticate, authorize('members', 'write'), createClubTask);
router.post('/:clubId/materialize', authenticate, authorize('members', 'write'), materializeAutomatedClubTasks);
router.post('/:clubId/dismiss-suggestion', authenticate, authorize('members', 'write'), dismissAutomatedClubTaskSuggestion);
router.put('/:clubId/:taskId', authenticate, authorize('members', 'write'), updateClubTask);
router.patch('/:clubId/:taskId/status', authenticate, authorize('members', 'write'), updateClubTaskStatus);
router.delete('/:clubId/:taskId', authenticate, authorize('members', 'write'), deleteClubTask);
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, ClubRole, ClubUserRole
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -68,6 +68,12 @@ 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 schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -368,6 +374,12 @@ 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);
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {
@@ -571,6 +583,9 @@ 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(Log);
await safeSync(Member);
await safeSync(DiaryDate);

View File

@@ -0,0 +1,228 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import ClubAccount from '../models/ClubAccount.js';
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 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;
}
}
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) {
return ClubAccount.findAll({
where: { clubId },
order: [
['isDefault', 'DESC'],
['status', 'ASC'],
['sortOrder', 'ASC'],
['name', 'ASC'],
],
});
}
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,155 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import { ClubPaymentClaim, ClubRequest, ClubTask, Member } from '../models/index.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 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 = 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,285 @@
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 dueDate = claim.dueOn ? new Date(claim.dueOn) : null;
const monthKey = getMonthKey(claim.dueOn || claim.createdAt);
const monthEntry = monthKey ? monthMap.get(monthKey) : null;
if (claim.status === 'paid') {
paymentTotals.paidCount += 1;
paymentTotals.paidAmountCents += amount;
if (monthEntry) {
monthEntry.claimPaidCount += 1;
monthEntry.claimPaidAmountCents += amount;
}
} else if (['open', 'partially_paid'].includes(claim.status)) {
paymentTotals.openCount += 1;
paymentTotals.openAmountCents += amount;
if (dueDate && dueDate < today) {
paymentTotals.overdueCount += 1;
paymentTotals.overdueAmountCents += amount;
}
if (monthEntry) {
monthEntry.claimOpenCount += 1;
monthEntry.claimOpenAmountCents += amount;
}
}
}
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,556 @@
import { Op } from 'sequelize';
import {
CalendarEvent,
ClubPaymentClaim,
ClubRequest,
ClubSepaMandate,
ClubTask,
ClubTaskSuppression,
Member,
} from '../models/index.js';
import { CLUB_TASK_DEFINITIONS, getClubTaskDefinitionMap } from './clubTaskDefinitions.js';
const definitionMap = getClubTaskDefinitionMap();
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 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),
};
}
class ClubTaskAutomationService {
async buildAutomationOverview(clubId) {
const today = todayStart();
const [currentTasks, requests, members, mandates, paymentClaims, events, suppressions] = await Promise.all([
ClubTask.findAll({
where: {
clubId,
automationKey: { [Op.ne]: null },
},
}),
ClubRequest.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'in_progress', 'waiting'] },
},
order: [['receivedAt', 'ASC']],
}),
Member.findAll({
where: {
clubId,
active: true,
},
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
}),
ClubSepaMandate.findAll({
where: {
clubId,
status: 'active',
revokedAt: null,
memberId: { [Op.ne]: null },
},
attributes: ['memberId'],
}),
ClubPaymentClaim.findAll({
where: {
clubId,
status: { [Op.in]: ['open', 'partially_paid'] },
archivedAt: null,
},
order: [['dueOn', 'ASC']],
}),
CalendarEvent.findAll({
where: {
clubId,
endDate: { [Op.gte]: today.toISOString().slice(0, 10) },
},
order: [['startDate', 'ASC']],
limit: 20,
}),
ClubTaskSuppression.findAll({
where: { clubId },
attributes: ['automationKey', 'suppressionToken'],
}).catch((error) => {
if (error?.original?.code === 'ER_NO_SUCH_TABLE'
&& /club_task_suppressions/.test(String(error?.original?.sqlMessage || ''))) {
return [];
}
throw error;
}),
]);
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 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 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'
? `Mahnfall ${claim.id} prüfen`
: claimTaskType === 'payment_claim_overdue'
? `Überfällige Zahlung ${claim.id} nachfassen`
: `Fällige Zahlung ${claim.id} vorbereiten`,
description:
claimTaskType === 'payment_claim_reminder'
? `Offene Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} mit bestehender Mahnstufe prüfen.`
: claimTaskType === 'payment_claim_overdue'
? `Überfällige Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} priorisiert nachverfolgen.`
: `Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} vor Fälligkeit organisatorisch vorbereiten.`,
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),
currencyCode: claim.currencyCode,
dueOn: claim.dueOn,
status: claim.status,
reminderLevel: claim.reminderLevel,
},
});
}
for (const event of events) {
if (hasExistingSourceTask(currentTasks, 'calendar_event', event.id, 'calendar_events')) continue;
const dueDate = event.startDate ? addDays(new Date(event.startDate), -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.category]),
sourceEntityType: 'calendar_event',
sourceEntityId: event.id,
sourceSnapshot: {
title: event.title,
startDate: event.startDate,
endDate: event.endDate,
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,
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,
});
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,196 @@
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: [],
},
{
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: '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 function getClubTaskDefinitionMap() {
return CLUB_TASK_DEFINITIONS.reduce((accumulator, definition) => {
accumulator[definition.key] = definition;
return accumulator;
}, {});
}

View File

@@ -0,0 +1,65 @@
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' };
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

@@ -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 || ''))) {

View File

@@ -1,13 +1,8 @@
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 },
@@ -17,10 +12,10 @@ const ROLE_PERMISSIONS = {
tournaments: { read: true, write: true, delete: true },
statistics: { 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 },
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 },
@@ -33,7 +28,7 @@ const ROLE_PERMISSIONS = {
permissions: { read: false, write: false },
approvals: { read: false, write: 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 },
@@ -46,7 +41,7 @@ const ROLE_PERMISSIONS = {
permissions: { read: false, write: false },
approvals: { read: false, write: 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 },
@@ -59,7 +54,7 @@ const ROLE_PERMISSIONS = {
permissions: { read: false, write: false },
approvals: { read: false, write: 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 },
@@ -72,306 +67,58 @@ const ROLE_PERMISSIONS = {
permissions: { read: false, write: false },
approvals: { read: false, write: false },
mytischtennis_admin: { read: false, write: false },
predefined_activities: { read: false, write: false, delete: 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: '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'] },
@@ -384,10 +131,439 @@ class PermissionService {
permissions: { label: 'Berechtigungsverwaltung', actions: ['read', 'write'] },
approvals: { label: 'Freigaben (Mitgliedsanträge)', actions: ['read', 'write'] },
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] = 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,
},
});
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();