| Monat | +Neue Mitglieder | +Bestand zum Monatsende | +
|---|---|---|
| {{ entry.label }} | +{{ entry.newMembers }} | +{{ entry.memberCountSnapshot }} | +
diff --git a/backend/controllers/clubAccountController.js b/backend/controllers/clubAccountController.js new file mode 100644 index 00000000..105fcdfb --- /dev/null +++ b/backend/controllers/clubAccountController.js @@ -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(); diff --git a/backend/controllers/clubArchiveController.js b/backend/controllers/clubArchiveController.js new file mode 100644 index 00000000..8aa7cfaf --- /dev/null +++ b/backend/controllers/clubArchiveController.js @@ -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(); diff --git a/backend/controllers/clubDashboardController.js b/backend/controllers/clubDashboardController.js new file mode 100644 index 00000000..db5673e2 --- /dev/null +++ b/backend/controllers/clubDashboardController.js @@ -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.'), + }); + } +}; diff --git a/backend/controllers/clubRequestController.js b/backend/controllers/clubRequestController.js new file mode 100644 index 00000000..27fb6c6f --- /dev/null +++ b/backend/controllers/clubRequestController.js @@ -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.'), + }); + } +}; diff --git a/backend/controllers/clubStatisticsController.js b/backend/controllers/clubStatisticsController.js new file mode 100644 index 00000000..93b7f78a --- /dev/null +++ b/backend/controllers/clubStatisticsController.js @@ -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(); diff --git a/backend/controllers/clubTaskController.js b/backend/controllers/clubTaskController.js new file mode 100644 index 00000000..49485922 --- /dev/null +++ b/backend/controllers/clubTaskController.js @@ -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.'), + }); + } +}; diff --git a/backend/controllers/memberController.js b/backend/controllers/memberController.js index 3cf4703c..a6f01394 100644 --- a/backend/controllers/memberController.js +++ b/backend/controllers/memberController.js @@ -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, diff --git a/backend/controllers/permissionController.js b/backend/controllers/permissionController.js index 43306eab..923a70d8 100644 --- a/backend/controllers/permissionController.js +++ b/backend/controllers/permissionController.js @@ -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, }; - diff --git a/backend/middleware/authorizationMiddleware.js b/backend/middleware/authorizationMiddleware.js index da599a6e..550c7730 100644 --- a/backend/middleware/authorizationMiddleware.js +++ b/backend/middleware/authorizationMiddleware.js @@ -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 }; - diff --git a/backend/models/ClubAccount.js b/backend/models/ClubAccount.js new file mode 100644 index 00000000..50fc3b11 --- /dev/null +++ b/backend/models/ClubAccount.js @@ -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; diff --git a/backend/models/ClubPaymentClaim.js b/backend/models/ClubPaymentClaim.js new file mode 100644 index 00000000..26389a46 --- /dev/null +++ b/backend/models/ClubPaymentClaim.js @@ -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; diff --git a/backend/models/ClubRequest.js b/backend/models/ClubRequest.js new file mode 100644 index 00000000..f27e922e --- /dev/null +++ b/backend/models/ClubRequest.js @@ -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; diff --git a/backend/models/ClubRequestNote.js b/backend/models/ClubRequestNote.js new file mode 100644 index 00000000..66d368ae --- /dev/null +++ b/backend/models/ClubRequestNote.js @@ -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; diff --git a/backend/models/ClubRole.js b/backend/models/ClubRole.js new file mode 100644 index 00000000..543e0dae --- /dev/null +++ b/backend/models/ClubRole.js @@ -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; diff --git a/backend/models/ClubSepaMandate.js b/backend/models/ClubSepaMandate.js new file mode 100644 index 00000000..b042ce7f --- /dev/null +++ b/backend/models/ClubSepaMandate.js @@ -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; diff --git a/backend/models/ClubTask.js b/backend/models/ClubTask.js new file mode 100644 index 00000000..6ef62306 --- /dev/null +++ b/backend/models/ClubTask.js @@ -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; diff --git a/backend/models/ClubTaskSuppression.js b/backend/models/ClubTaskSuppression.js new file mode 100644 index 00000000..1392ec5f --- /dev/null +++ b/backend/models/ClubTaskSuppression.js @@ -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; diff --git a/backend/models/ClubUserRole.js b/backend/models/ClubUserRole.js new file mode 100644 index 00000000..9806c36f --- /dev/null +++ b/backend/models/ClubUserRole.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index 94abf307..91c0266c 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, }; diff --git a/backend/routes/clubAccountRoutes.js b/backend/routes/clubAccountRoutes.js new file mode 100644 index 00000000..c1173656 --- /dev/null +++ b/backend/routes/clubAccountRoutes.js @@ -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; diff --git a/backend/routes/clubArchiveRoutes.js b/backend/routes/clubArchiveRoutes.js new file mode 100644 index 00000000..88435ed1 --- /dev/null +++ b/backend/routes/clubArchiveRoutes.js @@ -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; diff --git a/backend/routes/clubDashboardRoutes.js b/backend/routes/clubDashboardRoutes.js new file mode 100644 index 00000000..26c50528 --- /dev/null +++ b/backend/routes/clubDashboardRoutes.js @@ -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; diff --git a/backend/routes/clubRequestRoutes.js b/backend/routes/clubRequestRoutes.js new file mode 100644 index 00000000..d4f045b0 --- /dev/null +++ b/backend/routes/clubRequestRoutes.js @@ -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; diff --git a/backend/routes/clubStatisticsRoutes.js b/backend/routes/clubStatisticsRoutes.js new file mode 100644 index 00000000..1d3d5d75 --- /dev/null +++ b/backend/routes/clubStatisticsRoutes.js @@ -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; diff --git a/backend/routes/clubTaskRoutes.js b/backend/routes/clubTaskRoutes.js new file mode 100644 index 00000000..e0f16921 --- /dev/null +++ b/backend/routes/clubTaskRoutes.js @@ -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; diff --git a/backend/routes/memberRoutes.js b/backend/routes/memberRoutes.js index 98ea50c3..338992e1 100644 --- a/backend/routes/memberRoutes.js +++ b/backend/routes/memberRoutes.js @@ -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); diff --git a/backend/routes/permissionRoutes.js b/backend/routes/permissionRoutes.js index c77c40c6..4b4c626d 100644 --- a/backend/routes/permissionRoutes.js +++ b/backend/routes/permissionRoutes.js @@ -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; - diff --git a/backend/server.js b/backend/server.js index 48d28402..e00790ce 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/backend/services/clubAccountService.js b/backend/services/clubAccountService.js new file mode 100644 index 00000000..5fb74ac4 --- /dev/null +++ b/backend/services/clubAccountService.js @@ -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(); diff --git a/backend/services/clubArchiveService.js b/backend/services/clubArchiveService.js new file mode 100644 index 00000000..0b65eee9 --- /dev/null +++ b/backend/services/clubArchiveService.js @@ -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(); diff --git a/backend/services/clubStatisticsService.js b/backend/services/clubStatisticsService.js new file mode 100644 index 00000000..f0abac9b --- /dev/null +++ b/backend/services/clubStatisticsService.js @@ -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(); diff --git a/backend/services/clubTaskAutomationService.js b/backend/services/clubTaskAutomationService.js new file mode 100644 index 00000000..a49c4984 --- /dev/null +++ b/backend/services/clubTaskAutomationService.js @@ -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(); diff --git a/backend/services/clubTaskDefinitions.js b/backend/services/clubTaskDefinitions.js new file mode 100644 index 00000000..be76517b --- /dev/null +++ b/backend/services/clubTaskDefinitions.js @@ -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; + }, {}); +} diff --git a/backend/services/clubWorkflowSourceService.js b/backend/services/clubWorkflowSourceService.js new file mode 100644 index 00000000..8d960aab --- /dev/null +++ b/backend/services/clubWorkflowSourceService.js @@ -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(); diff --git a/backend/services/memberService.js b/backend/services/memberService.js index c18a700e..2ca8f808 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -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 || ''))) { diff --git a/backend/services/permissionService.js b/backend/services/permissionService.js index 02855a07..ca74d89a 100644 --- a/backend/services/permissionService.js +++ b/backend/services/permissionService.js @@ -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(); - diff --git a/frontend/MULTI_PRODUCT_PLAN.md b/frontend/MULTI_PRODUCT_PLAN.md new file mode 100644 index 00000000..74c3f612 --- /dev/null +++ b/frontend/MULTI_PRODUCT_PLAN.md @@ -0,0 +1,80 @@ +# Multi-Product Umbau fuer `mein-tt.de` und `tt-verein.de` + +## Summary +- Das Frontend wird von einer einheitlichen Vereins-App zu zwei klar getrennten Produkten auf gemeinsamer technischer Basis umgebaut: + - `tt-verein.de` fuer Vereinsarbeit + - `mein-tt.de` fuer einzelne Spieler +- Im ersten Schritt wird nur die Architektur und Produkttrennung umgesetzt, nicht der volle Ausbau neuer Spieler- oder Finanzmodule. +- Backend, Auth und Accounts bleiben vorerst gemeinsam; die Domain bestimmt Produktkontext, sichtbare Navigation, Standard-Startseite, zugelassene Routen und SEO. + +## Implementation Changes +- Einen zentralen Produktkontext einfuehren, der beim App-Start den Host auf ein Produkt mapped. + - `tt-verein.de` => `club` + - `mein-tt.de` => `player` + - lokale Entwicklung zusaetzlich per Env-Override steuerbar, damit beide Produkte ohne DNS testbar bleiben +- Den Produktkontext zentral bereitstellen, statt `window.location` spaeter verteilt in Views zu pruefen. + - Store erweitert um `appProduct`, `appBrand`, `defaultHomeRoute` + - kleine Konfigurationsquelle fuer Produkt-Metadaten, erlaubte Routen, SEO-Basisdaten und Navigationsdefinitionen + +- Routing auf Produktfaehigkeit umstellen. + - In `src/router.js` jede interne Route mit Produkt-Metadaten versehen, z. B. `products: ['club']`, `['player']`, `['club', 'player']` + - Globaler Router-Guard blockiert direkte URL-Zugriffe auf fachlich unpassende Bereiche + - Bei gesperrten Routen Umleitung auf produktpassende Startseite statt stiller Anzeige +- Produktzuordnung im ersten Schritt: + - `tt-verein.de`: bestehende Vereinsbereiche bleiben zugelassen, insbesondere Mitglieder, Tagebuch, Kalender, Freigaben, Statistiken, Turniere, Spielplaene, Vereinssettings, Teamverwaltung, Abrechnung + - `mein-tt.de`: zunaechst nur persoenliche Bereiche plus Kalender + - konkret freigegeben auf `mein-tt.de`: Startseite, Login/Register/Passwort-Flows, Kalender, persoenliche Einstellungen, MyTischtennis-/click-TT-Konto, Bestellungen, Impressum/Datenschutz/Konto loeschen + - alle klar vereinszentrierten Bereiche auf `mein-tt.de` sperren, einschliesslich Club-Auswahl als Primaernavigation, Mitgliederverwaltung, Freigaben, Teamverwaltung, Vereinssettings, Billing, Turnier- und Spielplan-Arbeitsflaechen + - falls einzelne heute technisch noch `currentClub` voraussetzen, bleiben sie auf `mein-tt.de` zunaechst ebenfalls gesperrt, bis sie produktneutral gemacht sind + +- Navigation aus `App.vue` heraus in deklarative Produktnavigation ueberfuehren. + - keine fest verdrahteten Link-Bloecke mehr pro Template-Abschnitt + - Menue wird aus einer Konfigurationsliste gerendert: Label, Route, Icon, Permission-Regeln, Produktzuordnung + - `tt-verein.de` behaelt Club-Selektor und vereinszentrierte Sidebar + - `mein-tt.de` erhaelt eine reduzierte persoenliche Navigation ohne Club-Selektor als dominantes Element +- Onboarding/Startverhalten trennen. + - `tt-verein.de`: nach Login weiter club-zentriert; wenn kein Verein gewaehlt/verfuegbar, Club-Auswahl bzw. `createclub` + - `mein-tt.de`: nach Login auf persoenliche Startseite; kein erzwungener Club-Schritt + - bestehende Logik, die Navigation erst bei `selectedClub` sichtbar macht, wird fuer das Player-Produkt entkoppelt + +- Public Surface und SEO pro Produkt trennen. + - `src/utils/seo.js` nicht mehr auf `https://tt-tagebuch.de` fest verdrahten + - produktabhaengige Canonical-URL, Seitentitel, OG-Daten und Standardbeschreibungen + - `index.html` ohne feste Canonical auf alte Domain + - oeffentliche Landingpages auf `tt-verein.de` vereinszentriert belassen + - `mein-tt.de` bekommt eine eigene reduzierte oeffentliche Positionierung fuer Spieler, auch wenn die neuen Spielerfeatures fachlich erst spaeter kommen + +## Public Interfaces / Config Changes +- Neue zentrale Produktkonfiguration, z. B. in einer Datei wie `src/config/products.js` + - Hostname -> Produkt + - Produktname/Brand + - Default-Route + - erlaubte Routen + - SEO-Basiswerte +- Route-Meta wird erweitert um Produkt-Sichtbarkeit. +- Optionale Env-Variablen fuer lokale und Deployment-seitige Steuerung: + - Produkt-Override fuer lokale Entwicklung + - optionale Host-/Canonical-Basis-URLs je Produkt + +## Test Plan +- Router-Guard: + - `mein-tt.de` blockiert direkte Aufrufe von `/members`, `/billing`, `/club-settings`, `/team-management` + - `tt-verein.de` laesst diese Routen bei Auth weiter zu +- Navigation: + - auf `mein-tt.de` erscheinen keine vereinszentrierten Menuepunkte + - auf `tt-verein.de` bleibt die bestehende Vereinsnavigation erhalten +- Onboarding: + - Login auf `mein-tt.de` landet ohne Club-Zwang auf persoenlicher Startseite + - Login auf `tt-verein.de` bleibt club-zentriert +- SEO: + - Canonical, Title und Description wechseln je Host korrekt + - oeffentliche Vereins-Landingpages referenzieren `tt-verein.de`, nicht mehr `tt-tagebuch.de` +- Regression: + - bestehende Auth-Flows, Club-Wechsel und Permission-basierte Vereinsnavigation funktionieren auf `tt-verein.de` unveraendert weiter + +## Assumptions +- Gemeinsames Backend und gemeinsame Accounts bleiben im ersten Schritt bestehen. +- Die Domain-Trennung ist fachlich hart: unpassende Bereiche werden nicht nur im Menue versteckt, sondern per Routing gesperrt. +- `mein-tt.de` ist im ersten Schritt bewusst schmal und zeigt nur vorhandene persoenliche bzw. unkritische Bereiche plus Kalender. +- Neue Spielerfunktionen wie individuelles Trainingsprogramm und Ziele sowie neue Vereinsmodule wie Budget/Finanzuebersichten/Rechnungen werden erst im naechsten Ausbau auf diese Architektur aufgesetzt. +- Die bisherige Marke `tt-tagebuch.de` wird technisch nicht mehr als primaere oeffentliche Canonical-Basis behandelt, sobald die neuen Domains live sind. diff --git a/frontend/index.html b/frontend/index.html index 341a451f..584574bf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -11,65 +11,48 @@ -
TT-Verein
++ Inaktive Mitglieder und archivierte Vereinsvorgänge getrennt vom aktiven Tagesgeschäft. +
+Bitte zuerst einen Verein auswählen, um das Archiv zu laden.
+Mitglieder, die nicht mehr aktiv geführt werden.
+| Name | +Ort | +Zuletzt geändert | ++ | |
|---|---|---|---|---|
| {{ member.displayName }} | +{{ member.email || '–' }} | +{{ member.city || '–' }} | +{{ formatDateTime(member.updatedAt || member.createdAt) }} | ++ + | +
Abgelegte Vorgänge aus Kontakt, Probetraining, Mitgliedschaft oder Sponsoring.
+| Typ | +Betreff | +Person | +Zuletzt geändert | ++ |
|---|---|---|---|---|
| {{ displayRequestType(request.requestType) }} | +{{ request.subject || '–' }} | +{{ request.personName || request.email || '–' }} | +{{ formatDateTime(request.updatedAt || request.createdAt) }} | ++ + | +
Aufgaben, die bewusst aus dem aktiven Arbeitsvorrat ins Archiv verschoben wurden.
+| Titel | +Typ | +Priorität | +Archiviert am | ++ |
|---|---|---|---|---|
| {{ task.title }} | +{{ task.taskType || '–' }} | +{{ displayPriority(task.priority) }} | +{{ formatDateTime(task.archivedAt || task.updatedAt) }} | ++ + | +
Stornierte, abgeschriebene oder ausdrücklich archivierte Forderungen.
+| Mitglied | +Typ | +Status | +Fällig | +Betrag | +
|---|---|---|---|---|
| {{ claim.memberName || '–' }} | +{{ claim.claimType || '–' }} | +{{ displayClaimStatus(claim.status) }} | +{{ formatDate(claim.dueOn) }} | +{{ formatCurrency(claim.amountCents, claim.currencyCode) }} | +
{{ moduleSummary }}
++ Diese Fläche ist als konzeptioneller Arbeitsbereich für TT-Verein angelegt. Sie markiert + das Modul in der Produktstruktur und hält die Informationsarchitektur stabil, während die + konkrete Fachlogik schrittweise umgesetzt wird. +
+TT-Verein
++ Letzte Vorgänge aus Anfragen und Aufgaben für den Vereinsalltag in einer gemeinsamen Übersicht. +
+Bitte zuerst einen Verein auswählen, um die Vereinshistorie zu sehen.
+{{ entry.description }}
+ +TT-Verein
++ Kontaktanfragen, Probetrainings, Mitgliedschaftsanfragen und Sponsoringanfragen in einer Arbeitsfläche. +
+Bitte zuerst einen Verein auswählen, um Vereinsanfragen zu verwalten.
+