implementierung der ersten schritte eine komplett-suite

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

View File

@@ -0,0 +1,228 @@
import { Op } from 'sequelize';
import sequelize from '../database.js';
import ClubAccount from '../models/ClubAccount.js';
const ACCOUNT_TYPES = new Set(['bank', 'cash', 'virtual']);
const ACCOUNT_USAGE_TYPES = new Set(['general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash']);
const ACCOUNT_STATUSES = new Set(['active', 'inactive', 'archived']);
function trimText(value, maxLength = null) {
const normalized = String(value || '').trim();
if (!normalized) return null;
return maxLength ? normalized.slice(0, maxLength) : normalized;
}
function normalizeIban(value) {
const normalized = String(value || '').replace(/\s+/g, '').trim().toUpperCase();
return normalized || null;
}
function normalizeBic(value) {
const normalized = String(value || '').replace(/\s+/g, '').trim().toUpperCase();
return normalized || null;
}
function normalizePayload(payload = {}) {
const accountType = ACCOUNT_TYPES.has(payload.accountType) ? payload.accountType : 'bank';
const usageType = ACCOUNT_USAGE_TYPES.has(payload.usageType) ? payload.usageType : 'general';
const status = ACCOUNT_STATUSES.has(payload.status) ? payload.status : 'active';
return {
name: trimText(payload.name, 160),
accountHolder: trimText(payload.accountHolder, 255),
bankName: trimText(payload.bankName, 160),
iban: normalizeIban(payload.iban),
bic: normalizeBic(payload.bic),
accountType,
usageType,
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
allowSepaCollections: Boolean(payload.allowSepaCollections),
allowOutgoingPayments: Boolean(payload.allowOutgoingPayments),
isDefault: Boolean(payload.isDefault),
status,
notes: trimText(payload.notes),
sortOrder: Number.isFinite(Number(payload.sortOrder)) ? Number(payload.sortOrder) : 0,
};
}
function validatePayload(payload) {
if (!payload.name) {
const error = new Error('Kontobezeichnung ist erforderlich.');
error.status = 400;
throw error;
}
if (payload.allowSepaCollections && payload.accountType !== 'bank') {
const error = new Error('SEPA-Einzüge sind nur für Bankkonten möglich.');
error.status = 400;
throw error;
}
if (payload.allowSepaCollections && !payload.iban) {
const error = new Error('Für SEPA-Einzüge muss eine IBAN hinterlegt sein.');
error.status = 400;
throw error;
}
if (payload.status === 'archived' && payload.isDefault) {
const error = new Error('Ein archiviertes Konto kann nicht das Standardkonto sein.');
error.status = 400;
throw error;
}
}
async function ensureSingleDefault(clubId, accountId, transaction) {
await ClubAccount.update(
{ isDefault: false },
{
where: {
clubId,
id: { [Op.ne]: accountId },
},
transaction,
}
);
}
async function ensureFallbackDefault(clubId, transaction) {
const existingDefault = await ClubAccount.findOne({
where: {
clubId,
isDefault: true,
status: { [Op.ne]: 'archived' },
},
transaction,
});
if (existingDefault) {
return;
}
const fallback = await ClubAccount.findOne({
where: {
clubId,
status: { [Op.ne]: 'archived' },
},
order: [['sortOrder', 'ASC'], ['createdAt', 'ASC']],
transaction,
});
if (fallback) {
await fallback.update({ isDefault: true }, { transaction });
}
}
class ClubAccountService {
async listClubAccounts(clubId) {
return ClubAccount.findAll({
where: { clubId },
order: [
['isDefault', 'DESC'],
['status', 'ASC'],
['sortOrder', 'ASC'],
['name', 'ASC'],
],
});
}
async createClubAccount(clubId, payload) {
const normalized = normalizePayload(payload);
validatePayload(normalized);
return sequelize.transaction(async (transaction) => {
const account = await ClubAccount.create({
clubId,
...normalized,
archivedAt: normalized.status === 'archived' ? new Date() : null,
}, { transaction });
if (normalized.isDefault) {
await ensureSingleDefault(clubId, account.id, transaction);
} else {
await ensureFallbackDefault(clubId, transaction);
}
return account.reload({ transaction });
});
}
async updateClubAccount(clubId, accountId, payload) {
const account = await ClubAccount.findOne({
where: { id: accountId, clubId },
});
if (!account) {
const error = new Error('Konto wurde nicht gefunden.');
error.status = 404;
throw error;
}
const normalized = normalizePayload(payload);
validatePayload(normalized);
return sequelize.transaction(async (transaction) => {
await account.update({
...normalized,
archivedAt: normalized.status === 'archived'
? (account.archivedAt || new Date())
: null,
}, { transaction });
if (normalized.isDefault) {
await ensureSingleDefault(clubId, account.id, transaction);
}
await ensureFallbackDefault(clubId, transaction);
return account.reload({ transaction });
});
}
async updateClubAccountStatus(clubId, accountId, status) {
if (!ACCOUNT_STATUSES.has(status)) {
const error = new Error('Ungültiger Kontostatus.');
error.status = 400;
throw error;
}
const account = await ClubAccount.findOne({
where: { id: accountId, clubId },
});
if (!account) {
const error = new Error('Konto wurde nicht gefunden.');
error.status = 404;
throw error;
}
return sequelize.transaction(async (transaction) => {
const updatePayload = {
status,
archivedAt: status === 'archived' ? (account.archivedAt || new Date()) : null,
};
if (status === 'archived') {
updatePayload.isDefault = false;
}
await account.update(updatePayload, { transaction });
await ensureFallbackDefault(clubId, transaction);
return account.reload({ transaction });
});
}
async deleteClubAccount(clubId, accountId) {
const account = await ClubAccount.findOne({
where: { id: accountId, clubId },
});
if (!account) {
const error = new Error('Konto wurde nicht gefunden.');
error.status = 404;
throw error;
}
await sequelize.transaction(async (transaction) => {
await account.destroy({ transaction });
await ensureFallbackDefault(clubId, transaction);
});
}
}
export default new ClubAccountService();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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