implementierung der ersten schritte eine komplett-suite
This commit is contained in:
60
backend/controllers/clubAccountController.js
Normal file
60
backend/controllers/clubAccountController.js
Normal 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();
|
||||
19
backend/controllers/clubArchiveController.js
Normal file
19
backend/controllers/clubArchiveController.js
Normal 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();
|
||||
557
backend/controllers/clubDashboardController.js
Normal file
557
backend/controllers/clubDashboardController.js
Normal 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.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
174
backend/controllers/clubRequestController.js
Normal file
174
backend/controllers/clubRequestController.js
Normal 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.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
19
backend/controllers/clubStatisticsController.js
Normal file
19
backend/controllers/clubStatisticsController.js
Normal 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();
|
||||
274
backend/controllers/clubTaskController.js
Normal file
274
backend/controllers/clubTaskController.js
Normal 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.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
94
backend/models/ClubAccount.js
Normal file
94
backend/models/ClubAccount.js
Normal 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;
|
||||
78
backend/models/ClubPaymentClaim.js
Normal file
78
backend/models/ClubPaymentClaim.js
Normal 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;
|
||||
94
backend/models/ClubRequest.js
Normal file
94
backend/models/ClubRequest.js
Normal 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;
|
||||
32
backend/models/ClubRequestNote.js
Normal file
32
backend/models/ClubRequestNote.js
Normal 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;
|
||||
58
backend/models/ClubRole.js
Normal file
58
backend/models/ClubRole.js
Normal 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;
|
||||
64
backend/models/ClubSepaMandate.js
Normal file
64
backend/models/ClubSepaMandate.js
Normal 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;
|
||||
94
backend/models/ClubTask.js
Normal file
94
backend/models/ClubTask.js
Normal 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;
|
||||
31
backend/models/ClubTaskSuppression.js
Normal file
31
backend/models/ClubTaskSuppression.js
Normal 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;
|
||||
44
backend/models/ClubUserRole.js
Normal file
44
backend/models/ClubUserRole.js
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
16
backend/routes/clubAccountRoutes.js
Normal file
16
backend/routes/clubAccountRoutes.js
Normal 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;
|
||||
11
backend/routes/clubArchiveRoutes.js
Normal file
11
backend/routes/clubArchiveRoutes.js
Normal 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;
|
||||
10
backend/routes/clubDashboardRoutes.js
Normal file
10
backend/routes/clubDashboardRoutes.js
Normal 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;
|
||||
20
backend/routes/clubRequestRoutes.js
Normal file
20
backend/routes/clubRequestRoutes.js
Normal 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;
|
||||
11
backend/routes/clubStatisticsRoutes.js
Normal file
11
backend/routes/clubStatisticsRoutes.js
Normal 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;
|
||||
24
backend/routes/clubTaskRoutes.js
Normal file
24
backend/routes/clubTaskRoutes.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
228
backend/services/clubAccountService.js
Normal file
228
backend/services/clubAccountService.js
Normal 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();
|
||||
155
backend/services/clubArchiveService.js
Normal file
155
backend/services/clubArchiveService.js
Normal 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();
|
||||
285
backend/services/clubStatisticsService.js
Normal file
285
backend/services/clubStatisticsService.js
Normal 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();
|
||||
556
backend/services/clubTaskAutomationService.js
Normal file
556
backend/services/clubTaskAutomationService.js
Normal 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();
|
||||
196
backend/services/clubTaskDefinitions.js
Normal file
196
backend/services/clubTaskDefinitions.js
Normal 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;
|
||||
}, {});
|
||||
}
|
||||
65
backend/services/clubWorkflowSourceService.js
Normal file
65
backend/services/clubWorkflowSourceService.js
Normal 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();
|
||||
@@ -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 || ''))) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user