implementierung der ersten schritte eine komplett-suite
This commit is contained in:
228
backend/services/clubAccountService.js
Normal file
228
backend/services/clubAccountService.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Op } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import ClubAccount from '../models/ClubAccount.js';
|
||||
|
||||
const ACCOUNT_TYPES = new Set(['bank', 'cash', 'virtual']);
|
||||
const ACCOUNT_USAGE_TYPES = new Set(['general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash']);
|
||||
const ACCOUNT_STATUSES = new Set(['active', 'inactive', 'archived']);
|
||||
|
||||
function trimText(value, maxLength = null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return null;
|
||||
return maxLength ? normalized.slice(0, maxLength) : normalized;
|
||||
}
|
||||
|
||||
function normalizeIban(value) {
|
||||
const normalized = String(value || '').replace(/\s+/g, '').trim().toUpperCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeBic(value) {
|
||||
const normalized = String(value || '').replace(/\s+/g, '').trim().toUpperCase();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizePayload(payload = {}) {
|
||||
const accountType = ACCOUNT_TYPES.has(payload.accountType) ? payload.accountType : 'bank';
|
||||
const usageType = ACCOUNT_USAGE_TYPES.has(payload.usageType) ? payload.usageType : 'general';
|
||||
const status = ACCOUNT_STATUSES.has(payload.status) ? payload.status : 'active';
|
||||
return {
|
||||
name: trimText(payload.name, 160),
|
||||
accountHolder: trimText(payload.accountHolder, 255),
|
||||
bankName: trimText(payload.bankName, 160),
|
||||
iban: normalizeIban(payload.iban),
|
||||
bic: normalizeBic(payload.bic),
|
||||
accountType,
|
||||
usageType,
|
||||
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
|
||||
allowSepaCollections: Boolean(payload.allowSepaCollections),
|
||||
allowOutgoingPayments: Boolean(payload.allowOutgoingPayments),
|
||||
isDefault: Boolean(payload.isDefault),
|
||||
status,
|
||||
notes: trimText(payload.notes),
|
||||
sortOrder: Number.isFinite(Number(payload.sortOrder)) ? Number(payload.sortOrder) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function validatePayload(payload) {
|
||||
if (!payload.name) {
|
||||
const error = new Error('Kontobezeichnung ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (payload.allowSepaCollections && payload.accountType !== 'bank') {
|
||||
const error = new Error('SEPA-Einzüge sind nur für Bankkonten möglich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (payload.allowSepaCollections && !payload.iban) {
|
||||
const error = new Error('Für SEPA-Einzüge muss eine IBAN hinterlegt sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (payload.status === 'archived' && payload.isDefault) {
|
||||
const error = new Error('Ein archiviertes Konto kann nicht das Standardkonto sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSingleDefault(clubId, accountId, transaction) {
|
||||
await ClubAccount.update(
|
||||
{ isDefault: false },
|
||||
{
|
||||
where: {
|
||||
clubId,
|
||||
id: { [Op.ne]: accountId },
|
||||
},
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureFallbackDefault(clubId, transaction) {
|
||||
const existingDefault = await ClubAccount.findOne({
|
||||
where: {
|
||||
clubId,
|
||||
isDefault: true,
|
||||
status: { [Op.ne]: 'archived' },
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
if (existingDefault) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fallback = await ClubAccount.findOne({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.ne]: 'archived' },
|
||||
},
|
||||
order: [['sortOrder', 'ASC'], ['createdAt', 'ASC']],
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (fallback) {
|
||||
await fallback.update({ isDefault: true }, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
class ClubAccountService {
|
||||
async listClubAccounts(clubId) {
|
||||
return ClubAccount.findAll({
|
||||
where: { clubId },
|
||||
order: [
|
||||
['isDefault', 'DESC'],
|
||||
['status', 'ASC'],
|
||||
['sortOrder', 'ASC'],
|
||||
['name', 'ASC'],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async createClubAccount(clubId, payload) {
|
||||
const normalized = normalizePayload(payload);
|
||||
validatePayload(normalized);
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
const account = await ClubAccount.create({
|
||||
clubId,
|
||||
...normalized,
|
||||
archivedAt: normalized.status === 'archived' ? new Date() : null,
|
||||
}, { transaction });
|
||||
|
||||
if (normalized.isDefault) {
|
||||
await ensureSingleDefault(clubId, account.id, transaction);
|
||||
} else {
|
||||
await ensureFallbackDefault(clubId, transaction);
|
||||
}
|
||||
|
||||
return account.reload({ transaction });
|
||||
});
|
||||
}
|
||||
|
||||
async updateClubAccount(clubId, accountId, payload) {
|
||||
const account = await ClubAccount.findOne({
|
||||
where: { id: accountId, clubId },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizePayload(payload);
|
||||
validatePayload(normalized);
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
await account.update({
|
||||
...normalized,
|
||||
archivedAt: normalized.status === 'archived'
|
||||
? (account.archivedAt || new Date())
|
||||
: null,
|
||||
}, { transaction });
|
||||
|
||||
if (normalized.isDefault) {
|
||||
await ensureSingleDefault(clubId, account.id, transaction);
|
||||
}
|
||||
|
||||
await ensureFallbackDefault(clubId, transaction);
|
||||
return account.reload({ transaction });
|
||||
});
|
||||
}
|
||||
|
||||
async updateClubAccountStatus(clubId, accountId, status) {
|
||||
if (!ACCOUNT_STATUSES.has(status)) {
|
||||
const error = new Error('Ungültiger Kontostatus.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const account = await ClubAccount.findOne({
|
||||
where: { id: accountId, clubId },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
const updatePayload = {
|
||||
status,
|
||||
archivedAt: status === 'archived' ? (account.archivedAt || new Date()) : null,
|
||||
};
|
||||
if (status === 'archived') {
|
||||
updatePayload.isDefault = false;
|
||||
}
|
||||
|
||||
await account.update(updatePayload, { transaction });
|
||||
await ensureFallbackDefault(clubId, transaction);
|
||||
return account.reload({ transaction });
|
||||
});
|
||||
}
|
||||
|
||||
async deleteClubAccount(clubId, accountId) {
|
||||
const account = await ClubAccount.findOne({
|
||||
where: { id: accountId, clubId },
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await account.destroy({ transaction });
|
||||
await ensureFallbackDefault(clubId, transaction);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubAccountService();
|
||||
155
backend/services/clubArchiveService.js
Normal file
155
backend/services/clubArchiveService.js
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Op } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import { ClubPaymentClaim, ClubRequest, ClubTask, Member } from '../models/index.js';
|
||||
|
||||
const DEFAULT_LIMIT = 50;
|
||||
|
||||
function formatMemberName(member) {
|
||||
return [member?.firstName, member?.lastName].filter(Boolean).join(' ').trim() || `Mitglied #${member?.id}`;
|
||||
}
|
||||
|
||||
async function loadAvailableTables() {
|
||||
const tables = await sequelize.getQueryInterface().showAllTables();
|
||||
return new Set(
|
||||
tables
|
||||
.map((table) => (typeof table === 'string' ? table : Object.values(table || {})[0]))
|
||||
.filter(Boolean)
|
||||
.map((table) => String(table).toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
async function loadOptionalTableData(availableTables, tableName, loader, fallbackValue = []) {
|
||||
if (!availableTables.has(String(tableName).toLowerCase())) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return loader();
|
||||
}
|
||||
|
||||
class ClubArchiveService {
|
||||
async getClubArchive(clubIdRaw) {
|
||||
const clubId = Number.parseInt(clubIdRaw, 10);
|
||||
if (!Number.isFinite(clubId)) {
|
||||
const error = new Error('Ungültige clubId');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const availableTables = await loadAvailableTables();
|
||||
|
||||
const members = await Member.findAll({
|
||||
where: { clubId },
|
||||
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
const memberMap = new Map(members.map((member) => [Number(member.id), member]));
|
||||
|
||||
const archivedRequests = await loadOptionalTableData(
|
||||
availableTables,
|
||||
'club_requests',
|
||||
() => ClubRequest.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'archived',
|
||||
},
|
||||
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
|
||||
limit: DEFAULT_LIMIT,
|
||||
})
|
||||
);
|
||||
|
||||
const archivedTasks = await loadOptionalTableData(
|
||||
availableTables,
|
||||
'club_tasks',
|
||||
() => ClubTask.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'archived',
|
||||
},
|
||||
order: [['archivedAt', 'DESC'], ['updatedAt', 'DESC']],
|
||||
limit: DEFAULT_LIMIT,
|
||||
})
|
||||
);
|
||||
|
||||
const archivedClaims = await loadOptionalTableData(
|
||||
availableTables,
|
||||
'club_payment_claims',
|
||||
() => ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
[Op.or]: [
|
||||
{ archivedAt: { [Op.not]: null } },
|
||||
{ status: { [Op.in]: ['written_off', 'cancelled'] } },
|
||||
],
|
||||
},
|
||||
order: [['archivedAt', 'DESC'], ['updatedAt', 'DESC']],
|
||||
limit: DEFAULT_LIMIT,
|
||||
})
|
||||
);
|
||||
|
||||
const inactiveMembers = members
|
||||
.filter((member) => !member.active)
|
||||
.slice(0, DEFAULT_LIMIT)
|
||||
.map((member) => ({
|
||||
id: member.id,
|
||||
firstName: member.firstName || '',
|
||||
lastName: member.lastName || '',
|
||||
displayName: formatMemberName(member),
|
||||
email: member.email || '',
|
||||
city: member.city || '',
|
||||
createdAt: member.createdAt || null,
|
||||
updatedAt: member.updatedAt || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
summary: {
|
||||
inactiveMembers: members.filter((member) => !member.active).length,
|
||||
archivedRequests: archivedRequests.length,
|
||||
archivedTasks: archivedTasks.length,
|
||||
archivedClaims: archivedClaims.length,
|
||||
},
|
||||
inactiveMembers,
|
||||
archivedRequests: archivedRequests.map((entry) => ({
|
||||
id: entry.id,
|
||||
requestType: entry.requestType,
|
||||
status: entry.status,
|
||||
subject: entry.subject || '',
|
||||
personName: [entry.firstName, entry.lastName].filter(Boolean).join(' ').trim(),
|
||||
email: entry.email || '',
|
||||
updatedAt: entry.updatedAt || null,
|
||||
createdAt: entry.createdAt || null,
|
||||
})),
|
||||
archivedTasks: archivedTasks.map((entry) => ({
|
||||
id: entry.id,
|
||||
title: entry.title || '',
|
||||
taskType: entry.taskType || '',
|
||||
status: entry.status,
|
||||
priority: entry.priority || 'normal',
|
||||
dueAt: entry.dueAt || null,
|
||||
archivedAt: entry.archivedAt || null,
|
||||
updatedAt: entry.updatedAt || null,
|
||||
})),
|
||||
archivedClaims: archivedClaims.map((entry) => {
|
||||
const member = memberMap.get(Number(entry.memberId));
|
||||
return {
|
||||
id: entry.id,
|
||||
memberId: entry.memberId || null,
|
||||
memberName: member ? formatMemberName(member) : '',
|
||||
claimType: entry.claimType || 'membership_fee',
|
||||
status: entry.status,
|
||||
dueOn: entry.dueOn || null,
|
||||
amountCents: Number(entry.amountCents || 0),
|
||||
currencyCode: entry.currencyCode || 'EUR',
|
||||
settledAt: entry.settledAt || null,
|
||||
archivedAt: entry.archivedAt || null,
|
||||
updatedAt: entry.updatedAt || null,
|
||||
};
|
||||
}),
|
||||
notes: [
|
||||
'Das Vereinsarchiv zeigt inaktive Mitglieder sowie archivierte oder abgeschlossene Vereinsvorgänge.',
|
||||
'Ein zentrales Dokumentenarchiv wird ergänzt, sobald Dokumente und Rechnungen produktiv archiviert werden.',
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubArchiveService();
|
||||
285
backend/services/clubStatisticsService.js
Normal file
285
backend/services/clubStatisticsService.js
Normal file
@@ -0,0 +1,285 @@
|
||||
import { ClubPaymentClaim, ClubRequest, Member } from '../models/index.js';
|
||||
|
||||
function isMissingTableError(error, tableName) {
|
||||
return error?.original?.code === 'ER_NO_SUCH_TABLE'
|
||||
&& String(error?.original?.sqlMessage || '').includes(tableName);
|
||||
}
|
||||
|
||||
function getMonthKey(dateLike) {
|
||||
const date = new Date(dateLike);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function buildLastMonthsTemplate(monthCount = 12) {
|
||||
const months = [];
|
||||
const cursor = new Date();
|
||||
cursor.setDate(1);
|
||||
|
||||
for (let i = monthCount - 1; i >= 0; i -= 1) {
|
||||
const monthDate = new Date(cursor.getFullYear(), cursor.getMonth() - i, 1);
|
||||
months.push({
|
||||
key: getMonthKey(monthDate),
|
||||
label: `${String(monthDate.getMonth() + 1).padStart(2, '0')}.${monthDate.getFullYear()}`,
|
||||
newMembers: 0,
|
||||
memberCountSnapshot: 0,
|
||||
claimOpenAmountCents: 0,
|
||||
claimPaidAmountCents: 0,
|
||||
claimOpenCount: 0,
|
||||
claimPaidCount: 0,
|
||||
sponsorRequests: 0,
|
||||
sponsorOpenRequests: 0,
|
||||
sponsorConvertedRequests: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
function getAgeFromBirthDate(birthDate) {
|
||||
if (!birthDate || typeof birthDate !== 'string') return null;
|
||||
const value = birthDate.trim();
|
||||
if (!value) return null;
|
||||
|
||||
let year;
|
||||
let month;
|
||||
let day;
|
||||
|
||||
const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
const german = value.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
|
||||
|
||||
if (iso) {
|
||||
year = Number(iso[1]);
|
||||
month = Number(iso[2]) - 1;
|
||||
day = Number(iso[3]);
|
||||
} else if (german) {
|
||||
year = Number(german[3]);
|
||||
month = Number(german[2]) - 1;
|
||||
day = Number(german[1]);
|
||||
} else {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
year = parsed.getFullYear();
|
||||
month = parsed.getMonth();
|
||||
day = parsed.getDate();
|
||||
}
|
||||
|
||||
const birth = new Date(year, month, day);
|
||||
if (Number.isNaN(birth.getTime())) return null;
|
||||
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const monthDiff = today.getMonth() - birth.getMonth();
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||
age -= 1;
|
||||
}
|
||||
return age >= 0 ? age : null;
|
||||
}
|
||||
|
||||
function getAgeBucket(age) {
|
||||
if (age == null) return null;
|
||||
if (age < 12) return 'under_12';
|
||||
if (age < 18) return '12_17';
|
||||
if (age < 27) return '18_26';
|
||||
if (age < 41) return '27_40';
|
||||
if (age < 61) return '41_60';
|
||||
return '61_plus';
|
||||
}
|
||||
|
||||
const AGE_BUCKET_LABELS = {
|
||||
under_12: 'Unter 12',
|
||||
'12_17': '12 bis 17',
|
||||
'18_26': '18 bis 26',
|
||||
'27_40': '27 bis 40',
|
||||
'41_60': '41 bis 60',
|
||||
'61_plus': '61+',
|
||||
};
|
||||
|
||||
class ClubStatisticsService {
|
||||
async getClubStatistics(clubIdRaw) {
|
||||
const clubId = Number.parseInt(clubIdRaw, 10);
|
||||
if (!Number.isFinite(clubId)) {
|
||||
const error = new Error('Ungültige clubId');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const months = buildLastMonthsTemplate(12);
|
||||
const monthMap = new Map(months.map((entry) => [entry.key, entry]));
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const members = await Member.findAll({
|
||||
where: { clubId },
|
||||
order: [['createdAt', 'ASC']],
|
||||
});
|
||||
|
||||
let paymentClaims = [];
|
||||
try {
|
||||
paymentClaims = await ClubPaymentClaim.findAll({
|
||||
where: { clubId },
|
||||
order: [['dueOn', 'ASC']],
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isMissingTableError(error, 'club_payment_claims')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
let sponsorRequests = [];
|
||||
try {
|
||||
sponsorRequests = await ClubRequest.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
requestType: 'sponsoring',
|
||||
},
|
||||
order: [['receivedAt', 'ASC']],
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isMissingTableError(error, 'club_requests')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const memberOverview = {
|
||||
activeMembers: members.filter((member) => member.active && !member.testMembership).length,
|
||||
inactiveMembers: members.filter((member) => !member.active).length,
|
||||
testMembers: members.filter((member) => member.testMembership).length,
|
||||
createdThisYear: members.filter((member) => new Date(member.createdAt).getFullYear() === currentYear).length,
|
||||
};
|
||||
|
||||
const cumulativeMembers = [];
|
||||
let runningTotal = 0;
|
||||
for (const month of months) {
|
||||
const monthDate = new Date(`${month.key}-01T00:00:00`);
|
||||
const monthEnd = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
const newMembers = members.filter((member) => {
|
||||
const createdAt = new Date(member.createdAt);
|
||||
return createdAt >= monthDate && createdAt <= monthEnd;
|
||||
}).length;
|
||||
month.newMembers = newMembers;
|
||||
runningTotal += newMembers;
|
||||
month.memberCountSnapshot = runningTotal;
|
||||
cumulativeMembers.push(month);
|
||||
}
|
||||
|
||||
const ageCounts = {
|
||||
under_12: 0,
|
||||
'12_17': 0,
|
||||
'18_26': 0,
|
||||
'27_40': 0,
|
||||
'41_60': 0,
|
||||
'61_plus': 0,
|
||||
};
|
||||
let missingBirthdates = 0;
|
||||
let knownBirthdates = 0;
|
||||
for (const member of members.filter((entry) => entry.active)) {
|
||||
const age = getAgeFromBirthDate(member.birthDate);
|
||||
const bucket = getAgeBucket(age);
|
||||
if (!bucket) {
|
||||
missingBirthdates += 1;
|
||||
continue;
|
||||
}
|
||||
knownBirthdates += 1;
|
||||
ageCounts[bucket] += 1;
|
||||
}
|
||||
|
||||
const paymentTotals = {
|
||||
openCount: 0,
|
||||
openAmountCents: 0,
|
||||
paidCount: 0,
|
||||
paidAmountCents: 0,
|
||||
overdueCount: 0,
|
||||
overdueAmountCents: 0,
|
||||
};
|
||||
const today = new Date();
|
||||
for (const claim of paymentClaims) {
|
||||
const amount = Number(claim.amountCents || 0);
|
||||
const dueDate = claim.dueOn ? new Date(claim.dueOn) : null;
|
||||
const monthKey = getMonthKey(claim.dueOn || claim.createdAt);
|
||||
const monthEntry = monthKey ? monthMap.get(monthKey) : null;
|
||||
|
||||
if (claim.status === 'paid') {
|
||||
paymentTotals.paidCount += 1;
|
||||
paymentTotals.paidAmountCents += amount;
|
||||
if (monthEntry) {
|
||||
monthEntry.claimPaidCount += 1;
|
||||
monthEntry.claimPaidAmountCents += amount;
|
||||
}
|
||||
} else if (['open', 'partially_paid'].includes(claim.status)) {
|
||||
paymentTotals.openCount += 1;
|
||||
paymentTotals.openAmountCents += amount;
|
||||
if (dueDate && dueDate < today) {
|
||||
paymentTotals.overdueCount += 1;
|
||||
paymentTotals.overdueAmountCents += amount;
|
||||
}
|
||||
if (monthEntry) {
|
||||
monthEntry.claimOpenCount += 1;
|
||||
monthEntry.claimOpenAmountCents += amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sponsorTotals = {
|
||||
totalRequests: sponsorRequests.length,
|
||||
openRequests: sponsorRequests.filter((entry) => ['open', 'in_progress', 'waiting'].includes(entry.status)).length,
|
||||
convertedRequests: sponsorRequests.filter((entry) => entry.status === 'converted').length,
|
||||
archivedRequests: sponsorRequests.filter((entry) => ['archived', 'rejected'].includes(entry.status)).length,
|
||||
};
|
||||
for (const request of sponsorRequests) {
|
||||
const monthKey = getMonthKey(request.receivedAt || request.createdAt);
|
||||
const monthEntry = monthKey ? monthMap.get(monthKey) : null;
|
||||
if (!monthEntry) continue;
|
||||
monthEntry.sponsorRequests += 1;
|
||||
if (['open', 'in_progress', 'waiting'].includes(request.status)) {
|
||||
monthEntry.sponsorOpenRequests += 1;
|
||||
}
|
||||
if (request.status === 'converted') {
|
||||
monthEntry.sponsorConvertedRequests += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
overview: memberOverview,
|
||||
memberDevelopment: {
|
||||
monthly: cumulativeMembers.map((entry) => ({
|
||||
key: entry.key,
|
||||
label: entry.label,
|
||||
newMembers: entry.newMembers,
|
||||
memberCountSnapshot: entry.memberCountSnapshot,
|
||||
})),
|
||||
},
|
||||
ageStructure: {
|
||||
knownBirthdates,
|
||||
missingBirthdates,
|
||||
buckets: Object.entries(ageCounts).map(([key, count]) => ({
|
||||
key,
|
||||
label: AGE_BUCKET_LABELS[key] || key,
|
||||
count,
|
||||
})),
|
||||
},
|
||||
contributionDevelopment: {
|
||||
totals: paymentTotals,
|
||||
monthly: months.map((entry) => ({
|
||||
key: entry.key,
|
||||
label: entry.label,
|
||||
openAmountCents: entry.claimOpenAmountCents,
|
||||
paidAmountCents: entry.claimPaidAmountCents,
|
||||
openCount: entry.claimOpenCount,
|
||||
paidCount: entry.claimPaidCount,
|
||||
})),
|
||||
},
|
||||
sponsorDevelopment: {
|
||||
totals: sponsorTotals,
|
||||
monthly: months.map((entry) => ({
|
||||
key: entry.key,
|
||||
label: entry.label,
|
||||
totalRequests: entry.sponsorRequests,
|
||||
openRequests: entry.sponsorOpenRequests,
|
||||
convertedRequests: entry.sponsorConvertedRequests,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubStatisticsService();
|
||||
556
backend/services/clubTaskAutomationService.js
Normal file
556
backend/services/clubTaskAutomationService.js
Normal file
@@ -0,0 +1,556 @@
|
||||
import { Op } from 'sequelize';
|
||||
import {
|
||||
CalendarEvent,
|
||||
ClubPaymentClaim,
|
||||
ClubRequest,
|
||||
ClubSepaMandate,
|
||||
ClubTask,
|
||||
ClubTaskSuppression,
|
||||
Member,
|
||||
} from '../models/index.js';
|
||||
import { CLUB_TASK_DEFINITIONS, getClubTaskDefinitionMap } from './clubTaskDefinitions.js';
|
||||
|
||||
const definitionMap = getClubTaskDefinitionMap();
|
||||
|
||||
function activeTask(task) {
|
||||
return !['done', 'cancelled', 'archived'].includes(task.status);
|
||||
}
|
||||
|
||||
function buildAutomationKey(taskType, entityType, entityId, suffix = '') {
|
||||
return [taskType, entityType, entityId, suffix].filter(Boolean).join(':');
|
||||
}
|
||||
|
||||
function todayStart() {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function addDays(baseDate, days) {
|
||||
const date = new Date(baseDate);
|
||||
date.setDate(date.getDate() + days);
|
||||
return date;
|
||||
}
|
||||
|
||||
function daysUntil(targetDate, today) {
|
||||
return Math.floor((targetDate.getTime() - today.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
function derivePriority(days) {
|
||||
if (days < 0) return 'urgent';
|
||||
if (days <= 2) return 'high';
|
||||
if (days <= 7) return 'normal';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function personName(entity) {
|
||||
return [entity?.firstName, entity?.lastName].filter(Boolean).join(' ').trim() || entity?.email || 'Unbekannt';
|
||||
}
|
||||
|
||||
function wrapSuggestion(suggestion) {
|
||||
return {
|
||||
...suggestion,
|
||||
definition: definitionMap[suggestion.taskType] || null,
|
||||
};
|
||||
}
|
||||
|
||||
function suggestionToken(parts = []) {
|
||||
return parts
|
||||
.map((part) => (part == null ? '' : String(part).trim()))
|
||||
.join('|');
|
||||
}
|
||||
|
||||
function hasExistingSourceTask(tasks, sourceEntityType, sourceEntityId, automationSource) {
|
||||
return tasks.some((task) =>
|
||||
task.relatedEntityType === sourceEntityType &&
|
||||
Number(task.relatedEntityId) === Number(sourceEntityId) &&
|
||||
task.automationSource === automationSource
|
||||
);
|
||||
}
|
||||
|
||||
function requestSuggestionFor(request, today) {
|
||||
const requestDate = new Date(request.receivedAt || today);
|
||||
const name = personName(request);
|
||||
|
||||
if (request.requestType === 'trial_training') {
|
||||
return {
|
||||
taskType: 'request_schedule_trial_training',
|
||||
title: `Probetraining für ${name} organisieren`,
|
||||
description: 'Anfrage prüfen, Trainingsgruppe auswählen und einen passenden Termin abstimmen.',
|
||||
priority: 'high',
|
||||
dueAt: addDays(requestDate, 2),
|
||||
};
|
||||
}
|
||||
|
||||
if (request.requestType === 'membership') {
|
||||
return {
|
||||
taskType: 'request_membership_review',
|
||||
title: `Mitgliedsanfrage von ${name} prüfen`,
|
||||
description: 'Unterlagen, Rückfragen und Aufnahmeentscheidung im Vereinskontext bearbeiten.',
|
||||
priority: 'high',
|
||||
dueAt: addDays(requestDate, 3),
|
||||
};
|
||||
}
|
||||
|
||||
if (request.requestType === 'sponsoring') {
|
||||
return {
|
||||
taskType: 'request_sponsoring_reply',
|
||||
title: `Sponsoringanfrage von ${name} nachfassen`,
|
||||
description: 'Sponsoringanfrage bewerten und nächsten Vereinskontakt vorbereiten.',
|
||||
priority: 'normal',
|
||||
dueAt: addDays(requestDate, 4),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
taskType: 'request_contact_reply',
|
||||
title: `Kontaktanfrage von ${name} beantworten`,
|
||||
description: 'Rückmeldung an die anfragende Person geben und Zuständigkeit festlegen.',
|
||||
priority: 'normal',
|
||||
dueAt: addDays(requestDate, 3),
|
||||
};
|
||||
}
|
||||
|
||||
class ClubTaskAutomationService {
|
||||
async buildAutomationOverview(clubId) {
|
||||
const today = todayStart();
|
||||
const [currentTasks, requests, members, mandates, paymentClaims, events, suppressions] = await Promise.all([
|
||||
ClubTask.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
automationKey: { [Op.ne]: null },
|
||||
},
|
||||
}),
|
||||
ClubRequest.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'in_progress', 'waiting'] },
|
||||
},
|
||||
order: [['receivedAt', 'ASC']],
|
||||
}),
|
||||
Member.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
active: true,
|
||||
},
|
||||
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
|
||||
}),
|
||||
ClubSepaMandate.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active',
|
||||
revokedAt: null,
|
||||
memberId: { [Op.ne]: null },
|
||||
},
|
||||
attributes: ['memberId'],
|
||||
}),
|
||||
ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
},
|
||||
order: [['dueOn', 'ASC']],
|
||||
}),
|
||||
CalendarEvent.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
endDate: { [Op.gte]: today.toISOString().slice(0, 10) },
|
||||
},
|
||||
order: [['startDate', 'ASC']],
|
||||
limit: 20,
|
||||
}),
|
||||
ClubTaskSuppression.findAll({
|
||||
where: { clubId },
|
||||
attributes: ['automationKey', 'suppressionToken'],
|
||||
}).catch((error) => {
|
||||
if (error?.original?.code === 'ER_NO_SUCH_TABLE'
|
||||
&& /club_task_suppressions/.test(String(error?.original?.sqlMessage || ''))) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}),
|
||||
]);
|
||||
|
||||
const existingKeys = new Set(currentTasks.filter(activeTask).map((task) => task.automationKey).filter(Boolean));
|
||||
const memberIdsWithMandate = new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
|
||||
const suppressionMap = new Map(
|
||||
suppressions
|
||||
.filter((entry) => entry?.automationKey && entry?.suppressionToken)
|
||||
.map((entry) => [String(entry.automationKey), String(entry.suppressionToken)])
|
||||
);
|
||||
const suggestions = [];
|
||||
|
||||
const pushSuggestion = (suggestion) => {
|
||||
const key = String(suggestion.automationKey || '');
|
||||
const token = String(suggestion.suppressionToken || '');
|
||||
if (key && token && suppressionMap.get(key) === token) {
|
||||
return;
|
||||
}
|
||||
suggestions.push(wrapSuggestion(suggestion));
|
||||
};
|
||||
|
||||
for (const request of requests) {
|
||||
const requestSuggestion = requestSuggestionFor(request, today);
|
||||
if (hasExistingSourceTask(currentTasks, 'club_request', request.id, 'club_requests')) continue;
|
||||
const key = buildAutomationKey(requestSuggestion.taskType, 'club_request', request.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
...requestSuggestion,
|
||||
automationSource: 'club_requests',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([
|
||||
request.updatedAt,
|
||||
request.status,
|
||||
request.workflowStage,
|
||||
request.convertedMemberId
|
||||
]),
|
||||
sourceEntityType: 'club_request',
|
||||
sourceEntityId: request.id,
|
||||
sourceSnapshot: {
|
||||
requestType: request.requestType,
|
||||
status: request.status,
|
||||
subject: request.subject || null,
|
||||
person: personName(request),
|
||||
convertedMemberId: request.convertedMemberId || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const member of members) {
|
||||
const name = personName(member);
|
||||
if (!String(member.email || '').trim()) {
|
||||
const key = buildAutomationKey('member_missing_email', 'member', member.id, 'email');
|
||||
if (!existingKeys.has(key)) {
|
||||
pushSuggestion({
|
||||
taskType: 'member_missing_email',
|
||||
title: `E-Mail für ${name} ergänzen`,
|
||||
description: 'Im Mitgliedsdatensatz fehlt eine E-Mail-Adresse.',
|
||||
priority: 'normal',
|
||||
dueAt: addDays(today, 7),
|
||||
automationSource: 'members',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([member.updatedAt, 'email']),
|
||||
sourceEntityType: 'member',
|
||||
sourceEntityId: member.id,
|
||||
sourceSnapshot: { memberName: name, missingField: 'email' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!String(member.birthDate || '').trim()) {
|
||||
const key = buildAutomationKey('member_missing_birthdate', 'member', member.id, 'birthdate');
|
||||
if (!existingKeys.has(key)) {
|
||||
pushSuggestion({
|
||||
taskType: 'member_missing_birthdate',
|
||||
title: `Geburtsdatum für ${name} ergänzen`,
|
||||
description: 'Im Mitgliedsdatensatz fehlt das Geburtsdatum.',
|
||||
priority: 'normal',
|
||||
dueAt: addDays(today, 7),
|
||||
automationSource: 'members',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([member.updatedAt, 'birthdate']),
|
||||
sourceEntityType: 'member',
|
||||
sourceEntityId: member.id,
|
||||
sourceSnapshot: { memberName: name, missingField: 'birthdate' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!memberIdsWithMandate.has(Number(member.id))) {
|
||||
const key = buildAutomationKey('member_missing_sepa_mandate', 'member', member.id);
|
||||
if (!existingKeys.has(key)) {
|
||||
pushSuggestion({
|
||||
taskType: 'member_missing_sepa_mandate',
|
||||
title: `SEPA-Mandat für ${name} einholen`,
|
||||
description: 'Für dieses aktive Mitglied liegt noch kein aktives SEPA-Mandat vor.',
|
||||
priority: 'high',
|
||||
dueAt: addDays(today, 5),
|
||||
automationSource: 'club_sepa_mandates',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([member.updatedAt, 'sepa-mandate-missing']),
|
||||
sourceEntityType: 'member',
|
||||
sourceEntityId: member.id,
|
||||
sourceSnapshot: { memberName: name },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const claim of paymentClaims) {
|
||||
const dueDate = claim.dueOn ? new Date(claim.dueOn) : today;
|
||||
const claimTaskType = Number(claim.reminderLevel || 0) > 0
|
||||
? 'payment_claim_reminder'
|
||||
: daysUntil(dueDate, today) < 0
|
||||
? 'payment_claim_overdue'
|
||||
: 'payment_claim_due_soon';
|
||||
const key = buildAutomationKey(claimTaskType, 'club_payment_claim', claim.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
taskType: claimTaskType,
|
||||
title:
|
||||
claimTaskType === 'payment_claim_reminder'
|
||||
? `Mahnfall ${claim.id} prüfen`
|
||||
: claimTaskType === 'payment_claim_overdue'
|
||||
? `Überfällige Zahlung ${claim.id} nachfassen`
|
||||
: `Fällige Zahlung ${claim.id} vorbereiten`,
|
||||
description:
|
||||
claimTaskType === 'payment_claim_reminder'
|
||||
? `Offene Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} mit bestehender Mahnstufe prüfen.`
|
||||
: claimTaskType === 'payment_claim_overdue'
|
||||
? `Überfällige Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} priorisiert nachverfolgen.`
|
||||
: `Forderung über ${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} vor Fälligkeit organisatorisch vorbereiten.`,
|
||||
priority: derivePriority(daysUntil(dueDate, today)),
|
||||
dueAt: dueDate,
|
||||
automationSource: 'club_payment_claims',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([claim.updatedAt, claim.status, claim.reminderLevel, claim.dueOn]),
|
||||
sourceEntityType: 'club_payment_claim',
|
||||
sourceEntityId: claim.id,
|
||||
sourceSnapshot: {
|
||||
amountCents: Number(claim.amountCents),
|
||||
currencyCode: claim.currencyCode,
|
||||
dueOn: claim.dueOn,
|
||||
status: claim.status,
|
||||
reminderLevel: claim.reminderLevel,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (hasExistingSourceTask(currentTasks, 'calendar_event', event.id, 'calendar_events')) continue;
|
||||
const dueDate = event.startDate ? addDays(new Date(event.startDate), -3) : addDays(today, 7);
|
||||
const eventTaskType = daysUntil(dueDate, today) <= 1 ? 'calendar_event_deadline_check' : 'calendar_event_prepare';
|
||||
const key = buildAutomationKey(eventTaskType, 'calendar_event', event.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
taskType: eventTaskType,
|
||||
title:
|
||||
eventTaskType === 'calendar_event_deadline_check'
|
||||
? `Letzte Prüfung für Termin: ${event.title}`
|
||||
: `Termin vorbereiten: ${event.title}`,
|
||||
description:
|
||||
eventTaskType === 'calendar_event_deadline_check'
|
||||
? 'Kurz vor dem Termin noch einmal Kommunikation, Teilnehmerstand und letzte Freigaben prüfen.'
|
||||
: 'Termin organisatorisch prüfen, Verantwortliche abstimmen und offene Punkte schließen.',
|
||||
priority: derivePriority(daysUntil(dueDate, today)),
|
||||
dueAt: dueDate,
|
||||
automationSource: 'calendar_events',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([event.updatedAt, event.startDate, event.endDate, event.category]),
|
||||
sourceEntityType: 'calendar_event',
|
||||
sourceEntityId: event.id,
|
||||
sourceSnapshot: {
|
||||
title: event.title,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
category: event.category || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
suggestions.sort((left, right) => {
|
||||
const leftDue = left.dueAt ? new Date(left.dueAt).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
const rightDue = right.dueAt ? new Date(right.dueAt).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
return leftDue - rightDue;
|
||||
});
|
||||
|
||||
return {
|
||||
definitions: CLUB_TASK_DEFINITIONS,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
async materializeSuggestions(clubId, userId, automationKeys = []) {
|
||||
const overview = await this.buildAutomationOverview(clubId);
|
||||
const matches = overview.suggestions.filter((suggestion) => automationKeys.includes(suggestion.automationKey));
|
||||
const tasks = [];
|
||||
|
||||
for (const suggestion of matches) {
|
||||
const task = await ClubTask.create({
|
||||
clubId,
|
||||
title: suggestion.title,
|
||||
taskType: suggestion.taskType,
|
||||
description: suggestion.description,
|
||||
status: 'open',
|
||||
priority: suggestion.priority,
|
||||
dueAt: suggestion.dueAt,
|
||||
createdByUserId: userId || null,
|
||||
automationSource: suggestion.automationSource,
|
||||
automationKey: suggestion.automationKey,
|
||||
relatedEntityType: suggestion.sourceEntityType,
|
||||
relatedEntityId: suggestion.sourceEntityId,
|
||||
sourceSnapshot: suggestion.sourceSnapshot,
|
||||
});
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
buildFollowUpSuggestionFromTask(task, nextTaskType) {
|
||||
const definition = definitionMap[nextTaskType];
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snapshot = task.sourceSnapshot || {};
|
||||
const person = snapshot.person || snapshot.memberName || 'Unbekannt';
|
||||
const sourceDate = task.dueAt ? new Date(task.dueAt) : todayStart();
|
||||
|
||||
switch (nextTaskType) {
|
||||
case 'request_trial_training_follow_up':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `Rückmeldung zu Probetraining von ${person} einholen`,
|
||||
description: 'Nach dem vereinbarten Probetraining Trainerfeedback und Rückmeldung des Interessenten einsammeln.',
|
||||
priority: 'high',
|
||||
dueAt: addDays(sourceDate, 2),
|
||||
automationSource: task.automationSource,
|
||||
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
|
||||
sourceEntityType: task.relatedEntityType,
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
case 'membership_prepare_admission':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `Aufnahme für ${person} vorbereiten`,
|
||||
description: 'Aufnahmeentscheidung vorbereiten, fehlende Freigaben klären und Übernahme in den Verein anstoßen.',
|
||||
priority: 'high',
|
||||
dueAt: addDays(sourceDate, 2),
|
||||
automationSource: task.automationSource,
|
||||
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
|
||||
sourceEntityType: task.relatedEntityType,
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
case 'membership_create_member_record':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `Mitgliedsdatensatz für ${person} anlegen`,
|
||||
description: 'Mitglied im System anlegen, Stammdaten prüfen und Vereinsstatus sauber setzen.',
|
||||
priority: 'high',
|
||||
dueAt: addDays(sourceDate, 1),
|
||||
automationSource: task.automationSource,
|
||||
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
|
||||
sourceEntityType: task.relatedEntityType,
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
case 'membership_collect_sepa_mandate':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `SEPA-Mandat für ${person} organisieren`,
|
||||
description: 'Für das neue Mitglied das SEPA-Mandat einholen oder auf Vollständigkeit prüfen.',
|
||||
priority: 'high',
|
||||
dueAt: addDays(sourceDate, 3),
|
||||
automationSource: task.automationSource,
|
||||
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
|
||||
sourceEntityType: task.relatedEntityType,
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
case 'membership_assign_fee':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `Beitragszuordnung für ${person} prüfen`,
|
||||
description: 'Beitragssatz, Ermäßigung oder Familienlogik für das neue Mitglied verbindlich festlegen.',
|
||||
priority: 'normal',
|
||||
dueAt: addDays(sourceDate, 2),
|
||||
automationSource: task.automationSource,
|
||||
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
|
||||
sourceEntityType: task.relatedEntityType,
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
case 'calendar_event_deadline_check':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `Letzte Prüfung für Termin: ${snapshot.title || task.title}`,
|
||||
description: 'Kurz vor dem Termin noch einmal Kommunikation, Teilnehmerstand und letzte Freigaben prüfen.',
|
||||
priority: 'high',
|
||||
dueAt: addDays(sourceDate, 1),
|
||||
automationSource: task.automationSource,
|
||||
automationKey: buildAutomationKey(nextTaskType, task.relatedEntityType, task.relatedEntityId),
|
||||
sourceEntityType: task.relatedEntityType,
|
||||
sourceEntityId: task.relatedEntityId,
|
||||
sourceSnapshot: snapshot,
|
||||
});
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async materializeWorkflowFollowUps(task, userId) {
|
||||
const definition = definitionMap[task.taskType];
|
||||
const nextTaskTypes = Array.isArray(definition?.nextTaskTypes) ? definition.nextTaskTypes : [];
|
||||
if (nextTaskTypes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const createdTasks = [];
|
||||
for (const nextTaskType of nextTaskTypes) {
|
||||
const followUp = this.buildFollowUpSuggestionFromTask(task, nextTaskType);
|
||||
if (!followUp) continue;
|
||||
|
||||
const existingTask = await ClubTask.findOne({
|
||||
where: {
|
||||
clubId: task.clubId,
|
||||
automationKey: followUp.automationKey,
|
||||
},
|
||||
});
|
||||
if (existingTask) continue;
|
||||
|
||||
const createdTask = await ClubTask.create({
|
||||
clubId: task.clubId,
|
||||
title: followUp.title,
|
||||
taskType: followUp.taskType,
|
||||
description: followUp.description,
|
||||
status: 'open',
|
||||
priority: followUp.priority,
|
||||
dueAt: followUp.dueAt,
|
||||
createdByUserId: userId || null,
|
||||
automationSource: followUp.automationSource,
|
||||
automationKey: followUp.automationKey,
|
||||
relatedEntityType: followUp.sourceEntityType,
|
||||
relatedEntityId: followUp.sourceEntityId,
|
||||
sourceSnapshot: followUp.sourceSnapshot,
|
||||
});
|
||||
createdTasks.push(createdTask);
|
||||
}
|
||||
|
||||
return createdTasks;
|
||||
}
|
||||
|
||||
async dismissSuggestion(clubId, userId, suggestionPayload = {}) {
|
||||
const automationKey = String(suggestionPayload.automationKey || '').trim();
|
||||
const suppressionTokenValue = String(suggestionPayload.suppressionToken || '').trim();
|
||||
|
||||
if (!automationKey || !suppressionTokenValue) {
|
||||
const error = new Error('Automatik-Schlüssel und Unterdrückungs-Token sind erforderlich.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [row] = await ClubTaskSuppression.findOrCreate({
|
||||
where: { clubId, automationKey },
|
||||
defaults: {
|
||||
clubId,
|
||||
automationKey,
|
||||
suppressionToken: suppressionTokenValue,
|
||||
dismissedByUserId: userId || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (row.suppressionToken !== suppressionTokenValue || Number(row.dismissedByUserId || 0) !== Number(userId || 0)) {
|
||||
row.suppressionToken = suppressionTokenValue;
|
||||
row.dismissedByUserId = userId || null;
|
||||
await row.save();
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubTaskAutomationService();
|
||||
196
backend/services/clubTaskDefinitions.js
Normal file
196
backend/services/clubTaskDefinitions.js
Normal file
@@ -0,0 +1,196 @@
|
||||
export const CLUB_TASK_DEFINITIONS = [
|
||||
{
|
||||
key: 'request_contact_reply',
|
||||
label: 'Kontaktanfrage beantworten',
|
||||
source: 'club_requests',
|
||||
category: 'Anfragen',
|
||||
workflow: 'Anfragebearbeitung',
|
||||
trigger: 'Kontaktanfrage ist offen oder wartet auf Rückmeldung.',
|
||||
description: 'Antwort an eine allgemeine Kontaktanfrage vorbereiten und den nächsten Vereinskontakt sichern.',
|
||||
suggestedAction: 'Antwort verfassen und zuständige Person festlegen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'request_schedule_trial_training',
|
||||
label: 'Probetraining organisieren',
|
||||
source: 'club_requests',
|
||||
category: 'Anfragen',
|
||||
workflow: 'Probetraining',
|
||||
trigger: 'Probetraining wurde angefragt und noch nicht konkret terminiert.',
|
||||
description: 'Probetraining terminieren, Ansprechpartner bestimmen und Rückmeldung an den Interessenten senden.',
|
||||
suggestedAction: 'Termin abstimmen, Trainingsgruppe auswählen, Einladung senden.',
|
||||
nextTaskTypes: ['request_trial_training_follow_up'],
|
||||
},
|
||||
{
|
||||
key: 'request_trial_training_follow_up',
|
||||
label: 'Nach Probetraining Rückmeldung einholen',
|
||||
source: 'club_requests',
|
||||
category: 'Anfragen',
|
||||
workflow: 'Probetraining',
|
||||
trigger: 'Probetraining-Anfrage ist in Bearbeitung oder wartet auf Entscheidung.',
|
||||
description: 'Nach dem Probetraining Rückmeldung einholen und über Mitgliedsantrag oder Absage entscheiden.',
|
||||
suggestedAction: 'Trainerfeedback holen und nächsten Schritt festlegen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'request_membership_review',
|
||||
label: 'Mitgliedsanfrage prüfen',
|
||||
source: 'club_requests',
|
||||
category: 'Anfragen',
|
||||
workflow: 'Mitgliedschaft',
|
||||
trigger: 'Mitgliedsanfrage ist offen oder unvollständig.',
|
||||
description: 'Mitgliedsantrag prüfen, fehlende Unterlagen nachfordern und Aufnahme vorbereiten.',
|
||||
suggestedAction: 'Unterlagen prüfen und Aufnahmeprozess anstoßen.',
|
||||
nextTaskTypes: ['membership_prepare_admission'],
|
||||
},
|
||||
{
|
||||
key: 'membership_prepare_admission',
|
||||
label: 'Aufnahme vorbereiten',
|
||||
source: 'club_requests',
|
||||
category: 'Anfragen',
|
||||
workflow: 'Mitgliedschaft',
|
||||
trigger: 'Mitgliedsanfrage wurde fachlich geprüft und soll in die Aufnahme überführt werden.',
|
||||
description: 'Aufnahmeentscheidung vorbereiten, Freigaben einholen und die formale Übernahme in den Verein anstoßen.',
|
||||
suggestedAction: 'Aufnahmestatus klären und Übergang in die Mitgliedsdaten vorbereiten.',
|
||||
nextTaskTypes: ['membership_create_member_record'],
|
||||
},
|
||||
{
|
||||
key: 'membership_create_member_record',
|
||||
label: 'Mitgliedsdatensatz anlegen',
|
||||
source: 'club_requests',
|
||||
category: 'Mitglieder',
|
||||
workflow: 'Mitgliedschaft',
|
||||
trigger: 'Aufnahme ist entschieden und der Datensatz muss im Verein sauber angelegt oder geprüft werden.',
|
||||
description: 'Mitglied im System anlegen, Stammdaten prüfen und den Vereinskontext vollständig herstellen.',
|
||||
suggestedAction: 'Mitgliedsnummer, Status und Basisdaten vervollständigen.',
|
||||
nextTaskTypes: ['membership_collect_sepa_mandate'],
|
||||
},
|
||||
{
|
||||
key: 'membership_collect_sepa_mandate',
|
||||
label: 'SEPA für neues Mitglied einholen',
|
||||
source: 'club_requests',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Mitgliedschaft',
|
||||
trigger: 'Neues Mitglied ist angelegt, aber der Beitragseinzug muss noch vorbereitet werden.',
|
||||
description: 'SEPA-Mandat für das neu aufgenommene Mitglied organisieren und den Beitragseinzug vorbereiten.',
|
||||
suggestedAction: 'Mandatsformular anfordern, prüfen oder zur Unterschrift versenden.',
|
||||
nextTaskTypes: ['membership_assign_fee'],
|
||||
},
|
||||
{
|
||||
key: 'membership_assign_fee',
|
||||
label: 'Beitragszuordnung prüfen',
|
||||
source: 'club_requests',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Mitgliedschaft',
|
||||
trigger: 'Mitglied ist angelegt und finanzseitig in die richtige Beitragslogik einzuordnen.',
|
||||
description: 'Passenden Beitragssatz, Ermäßigung oder Familienbeitrag für das neue Mitglied prüfen.',
|
||||
suggestedAction: 'Beitragsregel festlegen und Zuordnung kontrollieren.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'request_sponsoring_reply',
|
||||
label: 'Sponsoringanfrage nachfassen',
|
||||
source: 'club_requests',
|
||||
category: 'Anfragen',
|
||||
workflow: 'Sponsoring',
|
||||
trigger: 'Sponsoringanfrage ist offen oder wartet auf Vereinsreaktion.',
|
||||
description: 'Erstkontakt zu Sponsoringanfragen strukturieren und den nächsten Gesprächstermin vorbereiten.',
|
||||
suggestedAction: 'Ansprechpartner festlegen und Antwort mit weiterem Vorgehen senden.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'member_missing_email',
|
||||
label: 'Mitgliedsdaten ergänzen: E-Mail',
|
||||
source: 'members',
|
||||
category: 'Mitglieder',
|
||||
workflow: 'Datenqualität',
|
||||
trigger: 'Aktives Mitglied ohne E-Mail-Adresse.',
|
||||
description: 'Entsteht, wenn bei einem aktiven Mitglied keine E-Mail-Adresse gepflegt ist.',
|
||||
suggestedAction: 'Kontakt aufnehmen und E-Mail nachpflegen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'member_missing_birthdate',
|
||||
label: 'Mitgliedsdaten ergänzen: Geburtsdatum',
|
||||
source: 'members',
|
||||
category: 'Mitglieder',
|
||||
workflow: 'Datenqualität',
|
||||
trigger: 'Aktives Mitglied ohne Geburtsdatum.',
|
||||
description: 'Entsteht, wenn bei einem aktiven Mitglied kein Geburtsdatum gepflegt ist.',
|
||||
suggestedAction: 'Geburtsdatum verifizieren und im Datensatz ergänzen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'member_missing_sepa_mandate',
|
||||
label: 'SEPA-Mandat einholen',
|
||||
source: 'club_sepa_mandates',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Beitragseinzug',
|
||||
trigger: 'Aktives Mitglied ohne aktives SEPA-Mandat.',
|
||||
description: 'Entsteht, wenn ein aktives Mitglied noch kein aktives SEPA-Mandat hat.',
|
||||
suggestedAction: 'Mandatsformular anfordern oder zur Unterschrift versenden.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'payment_claim_due_soon',
|
||||
label: 'Fällige Zahlung vorbereiten',
|
||||
source: 'club_payment_claims',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Zahlungseingänge',
|
||||
trigger: 'Forderung ist bald fällig, aber noch nicht überfällig.',
|
||||
description: 'Vor Fälligkeit prüfen, ob der Beitragseinzug oder die Zahlungserinnerung vorbereitet ist.',
|
||||
suggestedAction: 'Einzug oder Zahlungserinnerung vorbereiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'payment_claim_overdue',
|
||||
label: 'Überfällige Zahlung nachfassen',
|
||||
source: 'club_payment_claims',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Zahlungseingänge',
|
||||
trigger: 'Forderung ist überfällig.',
|
||||
description: 'Überfällige Beiträge priorisiert nachverfolgen und den nächsten Mahn- oder Kontakt-Schritt auslösen.',
|
||||
suggestedAction: 'Mitglied kontaktieren oder Mahnstufe erhöhen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'payment_claim_reminder',
|
||||
label: 'Mahnstufe prüfen',
|
||||
source: 'club_payment_claims',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Zahlungseingänge',
|
||||
trigger: 'Offene Forderung hat bereits eine Mahnstufe.',
|
||||
description: 'Bestehende Mahnfälle prüfen und entscheiden, ob eine weitere Eskalation oder Klärung nötig ist.',
|
||||
suggestedAction: 'Mahnung, Rücksprache oder Teilzahlungsentscheidung vorbereiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'calendar_event_prepare',
|
||||
label: 'Termin vorbereiten',
|
||||
source: 'calendar_events',
|
||||
category: 'Termine',
|
||||
workflow: 'Terminorganisation',
|
||||
trigger: 'Termin rückt näher.',
|
||||
description: 'Entsteht vor anstehenden Vereins- und Kalenderterminen als organisatorische Wiedervorlage.',
|
||||
suggestedAction: 'Verantwortliche, Räume, Kommunikation und offene Punkte prüfen.',
|
||||
nextTaskTypes: ['calendar_event_deadline_check'],
|
||||
},
|
||||
{
|
||||
key: 'calendar_event_deadline_check',
|
||||
label: 'Terminfrist prüfen',
|
||||
source: 'calendar_events',
|
||||
category: 'Termine',
|
||||
workflow: 'Terminorganisation',
|
||||
trigger: 'Termin oder Frist steht kurzfristig bevor.',
|
||||
description: 'Kurzfristige Frist oder Veranstaltung vor Durchführung auf Vollständigkeit und Kommunikation prüfen.',
|
||||
suggestedAction: 'Teilnehmerstand, Erinnerungen und letzte Freigaben prüfen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
];
|
||||
|
||||
export function getClubTaskDefinitionMap() {
|
||||
return CLUB_TASK_DEFINITIONS.reduce((accumulator, definition) => {
|
||||
accumulator[definition.key] = definition;
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
65
backend/services/clubWorkflowSourceService.js
Normal file
65
backend/services/clubWorkflowSourceService.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { ClubRequest } from '../models/index.js';
|
||||
|
||||
function completedRequestStateForTaskType(taskType) {
|
||||
switch (taskType) {
|
||||
case 'request_contact_reply':
|
||||
return { status: 'waiting', workflowStage: 'contact_replied' };
|
||||
case 'request_schedule_trial_training':
|
||||
return { status: 'in_progress', workflowStage: 'trial_training_scheduled' };
|
||||
case 'request_trial_training_follow_up':
|
||||
return { status: 'waiting', workflowStage: 'trial_training_feedback_recorded' };
|
||||
case 'request_membership_review':
|
||||
return { status: 'in_progress', workflowStage: 'membership_reviewed' };
|
||||
case 'membership_prepare_admission':
|
||||
return { status: 'in_progress', workflowStage: 'admission_prepared' };
|
||||
case 'membership_create_member_record':
|
||||
return { status: 'in_progress', workflowStage: 'member_record_created' };
|
||||
case 'membership_collect_sepa_mandate':
|
||||
return { status: 'in_progress', workflowStage: 'sepa_pending' };
|
||||
case 'membership_assign_fee':
|
||||
return { status: 'converted', workflowStage: 'onboarding_completed', closedAt: new Date() };
|
||||
case 'request_sponsoring_reply':
|
||||
return { status: 'waiting', workflowStage: 'sponsoring_contacted' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ClubWorkflowSourceService {
|
||||
async syncSourceStateForCompletedTask(task) {
|
||||
if (task.relatedEntityType !== 'club_request' || !task.relatedEntityId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestState = completedRequestStateForTaskType(task.taskType);
|
||||
if (!requestState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const request = await ClubRequest.findOne({
|
||||
where: {
|
||||
id: task.relatedEntityId,
|
||||
clubId: task.clubId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await request.update({
|
||||
status: requestState.status,
|
||||
workflowStage: requestState.workflowStage,
|
||||
closedAt: requestState.closedAt || null,
|
||||
});
|
||||
|
||||
return {
|
||||
entityType: 'club_request',
|
||||
entityId: request.id,
|
||||
status: request.status,
|
||||
workflowStage: request.workflowStage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubWorkflowSourceService();
|
||||
@@ -5,6 +5,7 @@ import Member from "../models/Member.js";
|
||||
import MemberImage from "../models/MemberImage.js";
|
||||
import MemberTtrHistory from "../models/MemberTtrHistory.js";
|
||||
import MemberPlayInterest from "../models/MemberPlayInterest.js";
|
||||
import ClubSepaMandate from "../models/ClubSepaMandate.js";
|
||||
import Participant from "../models/Participant.js";
|
||||
import DiaryDate from "../models/DiaryDates.js";
|
||||
import { Op, fn, col } from 'sequelize';
|
||||
@@ -15,6 +16,132 @@ import sharp from 'sharp';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
import { standardizePhoneNumber } from '../utils/phoneUtils.js';
|
||||
class MemberService {
|
||||
normalizeSepaMandatePayload(payload = {}) {
|
||||
const normalizeText = (value, maxLength = null) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed) return null;
|
||||
return maxLength ? trimmed.slice(0, maxLength) : trimmed;
|
||||
};
|
||||
const normalizeDate = (value) => {
|
||||
const normalized = normalizeText(value, 10);
|
||||
return normalized || null;
|
||||
};
|
||||
|
||||
const status = normalizeText(payload.status, 32) || 'active';
|
||||
|
||||
return {
|
||||
debtorName: normalizeText(payload.debtorName, 255),
|
||||
iban: normalizeText(payload.iban, 34),
|
||||
bic: normalizeText(payload.bic, 11),
|
||||
mandateReference: normalizeText(payload.mandateReference, 80),
|
||||
signedOn: normalizeDate(payload.signedOn),
|
||||
validFrom: normalizeDate(payload.validFrom),
|
||||
status,
|
||||
historyNote: normalizeText(payload.historyNote),
|
||||
revokedAt: status === 'revoked'
|
||||
? (normalizeText(payload.revokedAt) || new Date().toISOString())
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
async getMemberSepaMandate(userToken, clubId, memberId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId } });
|
||||
if (!member) {
|
||||
return {
|
||||
status: 404,
|
||||
response: { success: false, error: 'membernotfound' }
|
||||
};
|
||||
}
|
||||
|
||||
const mandate = await ClubSepaMandate.findOne({
|
||||
where: { clubId, memberId },
|
||||
order: [['updatedAt', 'DESC'], ['id', 'DESC']]
|
||||
});
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
success: true,
|
||||
mandate: mandate ? mandate.toJSON() : null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async saveMemberSepaMandate(userToken, clubId, memberId, payload = {}) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId } });
|
||||
if (!member) {
|
||||
return {
|
||||
status: 404,
|
||||
response: { success: false, error: 'membernotfound' }
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedPayload = this.normalizeSepaMandatePayload(payload);
|
||||
const hasContent = Boolean(
|
||||
normalizedPayload.debtorName
|
||||
|| normalizedPayload.iban
|
||||
|| normalizedPayload.bic
|
||||
|| normalizedPayload.mandateReference
|
||||
|| normalizedPayload.signedOn
|
||||
|| normalizedPayload.validFrom
|
||||
|| normalizedPayload.historyNote
|
||||
);
|
||||
|
||||
let mandate = await ClubSepaMandate.findOne({
|
||||
where: { clubId, memberId },
|
||||
order: [['updatedAt', 'DESC'], ['id', 'DESC']]
|
||||
});
|
||||
|
||||
if (!mandate && !hasContent) {
|
||||
return {
|
||||
status: 200,
|
||||
response: { success: true, mandate: null }
|
||||
};
|
||||
}
|
||||
|
||||
if (!mandate) {
|
||||
if (!normalizedPayload.debtorName || !normalizedPayload.iban || !normalizedPayload.mandateReference) {
|
||||
return {
|
||||
status: 400,
|
||||
response: {
|
||||
success: false,
|
||||
code: 'missingrequiredsepafields',
|
||||
error: 'Bitte Kontoinhaber, IBAN und Mandatsreferenz angeben.'
|
||||
}
|
||||
};
|
||||
}
|
||||
mandate = await ClubSepaMandate.create({
|
||||
clubId,
|
||||
memberId,
|
||||
...normalizedPayload
|
||||
});
|
||||
} else {
|
||||
mandate.debtorName = normalizedPayload.debtorName;
|
||||
mandate.iban = normalizedPayload.iban;
|
||||
mandate.bic = normalizedPayload.bic;
|
||||
mandate.mandateReference = normalizedPayload.mandateReference;
|
||||
mandate.signedOn = normalizedPayload.signedOn;
|
||||
mandate.validFrom = normalizedPayload.validFrom;
|
||||
mandate.status = normalizedPayload.status;
|
||||
mandate.historyNote = normalizedPayload.historyNote;
|
||||
mandate.revokedAt = normalizedPayload.revokedAt;
|
||||
await mandate.save();
|
||||
}
|
||||
|
||||
await mandate.reload();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
response: {
|
||||
success: true,
|
||||
mandate: mandate.toJSON()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async getMemberPlayInterests(userToken, clubId, seasonId, lineupHalf) {
|
||||
await checkAccess(userToken, clubId);
|
||||
if (!seasonId || !['first_half', 'second_half'].includes(String(lineupHalf || ''))) {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import UserClub from '../models/UserClub.js';
|
||||
import Club from '../models/Club.js';
|
||||
import User from '../models/User.js';
|
||||
import ClubRole from '../models/ClubRole.js';
|
||||
import ClubUserRole from '../models/ClubUserRole.js';
|
||||
|
||||
/**
|
||||
* Permission Service
|
||||
* Handles all permission-related logic
|
||||
*/
|
||||
|
||||
// Default permissions for each role
|
||||
const ROLE_PERMISSIONS = {
|
||||
admin: {
|
||||
diary: { read: true, write: true, delete: true },
|
||||
@@ -17,10 +12,10 @@ const ROLE_PERMISSIONS = {
|
||||
tournaments: { read: true, write: true, delete: true },
|
||||
statistics: { read: true, write: true },
|
||||
settings: { read: true, write: true },
|
||||
permissions: { read: true, write: true }, // Can manage other users' permissions
|
||||
permissions: { read: true, write: true },
|
||||
approvals: { read: true, write: true },
|
||||
mytischtennis_admin: { read: true, write: true },
|
||||
predefined_activities: { read: true, write: true, delete: true }
|
||||
predefined_activities: { read: true, write: true, delete: true },
|
||||
},
|
||||
trainer: {
|
||||
diary: { read: true, write: true, delete: true },
|
||||
@@ -33,7 +28,7 @@ const ROLE_PERMISSIONS = {
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: true, write: true, delete: true }
|
||||
predefined_activities: { read: true, write: true, delete: true },
|
||||
},
|
||||
team_manager: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
@@ -46,7 +41,7 @@ const ROLE_PERMISSIONS = {
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false }
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
tournament_manager: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
@@ -59,7 +54,7 @@ const ROLE_PERMISSIONS = {
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false }
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
member: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
@@ -72,306 +67,58 @@ const ROLE_PERMISSIONS = {
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false }
|
||||
}
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_ROLE_TEMPLATES = [
|
||||
{ roleKey: 'admin', name: 'Administrator', description: 'Vollzugriff auf alle Funktionen', permissions: ROLE_PERMISSIONS.admin, sortOrder: 10 },
|
||||
{ roleKey: 'trainer', name: 'Trainer', description: 'Kann Trainingseinheiten, Mitglieder und Teams verwalten', permissions: ROLE_PERMISSIONS.trainer, sortOrder: 20 },
|
||||
{ roleKey: 'team_manager', name: 'Mannschaftsführer', description: 'Kann Teams und Spielpläne verwalten', permissions: ROLE_PERMISSIONS.team_manager, sortOrder: 30 },
|
||||
{ roleKey: 'tournament_manager', name: 'Turnierleiter', description: 'Kann Turniere verwalten', permissions: ROLE_PERMISSIONS.tournament_manager, sortOrder: 40 },
|
||||
{ roleKey: 'member', name: 'Mitglied', description: 'Kann nur freigegebene Vereinsbereiche ansehen', permissions: ROLE_PERMISSIONS.member, sortOrder: 50 },
|
||||
];
|
||||
|
||||
function cloneValue(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function normalizePermissions(value) {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return cloneValue(value);
|
||||
}
|
||||
|
||||
function slugifyRoleKey(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 64) || 'rolle';
|
||||
}
|
||||
|
||||
class PermissionService {
|
||||
/**
|
||||
* Get user's permissions for a specific club
|
||||
*/
|
||||
async getUserClubPermissions(userId, clubId) {
|
||||
const userClub = await UserClub.findOne({
|
||||
where: {
|
||||
userId,
|
||||
clubId,
|
||||
approved: true
|
||||
}
|
||||
});
|
||||
mergePermissions(basePermissions = {}, extraPermissions = {}) {
|
||||
const merged = cloneValue(basePermissions);
|
||||
const normalizedExtra = normalizePermissions(extraPermissions);
|
||||
|
||||
if (!userClub) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If user is owner, they have full admin rights
|
||||
if (userClub.isOwner) {
|
||||
return {
|
||||
role: 'admin',
|
||||
isOwner: true,
|
||||
permissions: ROLE_PERMISSIONS.admin
|
||||
};
|
||||
}
|
||||
|
||||
// Get role from database, fallback to 'member' if null/undefined
|
||||
const role = userClub.role || 'member';
|
||||
|
||||
// Get role-based permissions
|
||||
const rolePermissions = ROLE_PERMISSIONS[role] || ROLE_PERMISSIONS.member;
|
||||
|
||||
// Merge with custom permissions if any
|
||||
const customPermissions = userClub.permissions || {};
|
||||
const mergedPermissions = this.mergePermissions(rolePermissions, customPermissions);
|
||||
|
||||
return {
|
||||
role: role,
|
||||
isOwner: false,
|
||||
permissions: mergedPermissions
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has specific permission
|
||||
*/
|
||||
async hasPermission(userId, clubId, resource, action) {
|
||||
const userPermissions = await this.getUserClubPermissions(userId, clubId);
|
||||
|
||||
if (!userPermissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Owner always has permission
|
||||
if (userPermissions.isOwner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// MyTischtennis settings are accessible to all approved members
|
||||
if (resource === 'mytischtennis') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const resourcePermissions = userPermissions.permissions[resource];
|
||||
if (!resourcePermissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return resourcePermissions[action] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user role in club
|
||||
*/
|
||||
async setUserRole(userId, clubId, role, updatedByUserId) {
|
||||
// Check if updater has permission
|
||||
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
|
||||
if (!canManagePermissions) {
|
||||
throw new Error('Keine Berechtigung zum Ändern von Rollen');
|
||||
}
|
||||
|
||||
// Check if target user is owner
|
||||
const targetUserClub = await UserClub.findOne({
|
||||
where: { userId, clubId }
|
||||
});
|
||||
|
||||
if (!targetUserClub) {
|
||||
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
|
||||
}
|
||||
|
||||
if (targetUserClub.isOwner) {
|
||||
throw new Error('Die Rolle des Club-Erstellers kann nicht geändert werden');
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if (!ROLE_PERMISSIONS[role]) {
|
||||
throw new Error('Ungültige Rolle');
|
||||
}
|
||||
|
||||
await targetUserClub.update({ role });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Rolle erfolgreich aktualisiert'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom permissions for user
|
||||
*/
|
||||
async setCustomPermissions(userId, clubId, customPermissions, updatedByUserId) {
|
||||
// Check if updater has permission
|
||||
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
|
||||
if (!canManagePermissions) {
|
||||
throw new Error('Keine Berechtigung zum Ändern von Berechtigungen');
|
||||
}
|
||||
|
||||
// Check if target user is owner
|
||||
const targetUserClub = await UserClub.findOne({
|
||||
where: { userId, clubId }
|
||||
});
|
||||
|
||||
if (!targetUserClub) {
|
||||
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
|
||||
}
|
||||
|
||||
if (targetUserClub.isOwner) {
|
||||
throw new Error('Die Berechtigungen des Club-Erstellers können nicht geändert werden');
|
||||
}
|
||||
|
||||
await targetUserClub.update({ permissions: customPermissions });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Berechtigungen erfolgreich aktualisiert'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user status (activate/deactivate)
|
||||
*/
|
||||
async setUserStatus(userId, clubId, approved, updatedByUserId) {
|
||||
// Check if updater has permission
|
||||
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
|
||||
if (!canManagePermissions) {
|
||||
throw new Error('Keine Berechtigung zum Ändern des Status');
|
||||
}
|
||||
|
||||
// Check if target user is owner
|
||||
const targetUserClub = await UserClub.findOne({
|
||||
where: { userId, clubId }
|
||||
});
|
||||
|
||||
if (!targetUserClub) {
|
||||
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
|
||||
}
|
||||
|
||||
if (targetUserClub.isOwner) {
|
||||
throw new Error('Der Status des Club-Erstellers kann nicht geändert werden');
|
||||
}
|
||||
|
||||
await targetUserClub.update({ approved });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: approved ? 'Benutzer erfolgreich aktiviert' : 'Benutzer erfolgreich deaktiviert'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all club members with their permissions
|
||||
*/
|
||||
async getClubMembersWithPermissions(clubId, requestingUserId) {
|
||||
// Check if requester has permission to read permissions
|
||||
const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read');
|
||||
if (!canReadPermissions) {
|
||||
throw new Error('Keine Berechtigung zum Anzeigen von Berechtigungen');
|
||||
}
|
||||
|
||||
const userClubs = await UserClub.findAll({
|
||||
where: {
|
||||
clubId
|
||||
},
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user',
|
||||
attributes: ['id', 'email']
|
||||
}]
|
||||
});
|
||||
|
||||
return userClubs.map(uc => {
|
||||
// Parse permissions JSON string to object
|
||||
let parsedPermissions = null;
|
||||
if (uc.permissions) {
|
||||
try {
|
||||
parsedPermissions = typeof uc.permissions === 'string'
|
||||
? JSON.parse(uc.permissions)
|
||||
: uc.permissions;
|
||||
} catch (err) {
|
||||
console.error('Error parsing permissions JSON:', err);
|
||||
parsedPermissions = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userId: uc.userId,
|
||||
user: uc.user,
|
||||
role: uc.role,
|
||||
isOwner: uc.isOwner,
|
||||
approved: uc.approved,
|
||||
permissions: parsedPermissions,
|
||||
effectivePermissions: this.getEffectivePermissions(uc)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effective permissions (role + custom)
|
||||
*/
|
||||
getEffectivePermissions(userClub) {
|
||||
if (userClub.isOwner) {
|
||||
return ROLE_PERMISSIONS.admin;
|
||||
}
|
||||
|
||||
const rolePermissions = ROLE_PERMISSIONS[userClub.role] || ROLE_PERMISSIONS.member;
|
||||
|
||||
// Parse permissions JSON string to object
|
||||
let customPermissions = {};
|
||||
if (userClub.permissions) {
|
||||
try {
|
||||
customPermissions = typeof userClub.permissions === 'string'
|
||||
? JSON.parse(userClub.permissions)
|
||||
: userClub.permissions;
|
||||
} catch (err) {
|
||||
console.error('Error parsing permissions JSON in getEffectivePermissions:', err);
|
||||
customPermissions = {};
|
||||
}
|
||||
}
|
||||
|
||||
return this.mergePermissions(rolePermissions, customPermissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge role permissions with custom permissions
|
||||
*/
|
||||
mergePermissions(rolePermissions, customPermissions) {
|
||||
const merged = { ...rolePermissions };
|
||||
|
||||
for (const resource in customPermissions) {
|
||||
if (!merged[resource]) {
|
||||
merged[resource] = {};
|
||||
}
|
||||
for (const resource of Object.keys(normalizedExtra)) {
|
||||
merged[resource] = {
|
||||
...merged[resource],
|
||||
...customPermissions[resource]
|
||||
...(merged[resource] || {}),
|
||||
...(normalizedExtra[resource] || {}),
|
||||
};
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark user as club owner (used when creating a club)
|
||||
*/
|
||||
async setClubOwner(userId, clubId) {
|
||||
const userClub = await UserClub.findOne({
|
||||
where: { userId, clubId }
|
||||
});
|
||||
|
||||
if (!userClub) {
|
||||
throw new Error('UserClub relationship not found');
|
||||
}
|
||||
|
||||
await userClub.update({
|
||||
isOwner: true,
|
||||
role: 'admin',
|
||||
approved: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available roles
|
||||
*/
|
||||
getAvailableRoles() {
|
||||
return [
|
||||
{ value: 'admin', label: 'Administrator', description: 'Vollzugriff auf alle Funktionen' },
|
||||
{ value: 'trainer', label: 'Trainer', description: 'Kann Trainingseinheiten, Mitglieder und Teams verwalten' },
|
||||
{ value: 'team_manager', label: 'Mannschaftsführer', description: 'Kann Teams und Spielpläne verwalten' },
|
||||
{ value: 'tournament_manager', label: 'Turnierleiter', description: 'Kann Turniere verwalten' },
|
||||
{ value: 'member', label: 'Mitglied', description: 'Kann nur Trainings-Statistiken ansehen' }
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permission structure for frontend
|
||||
*/
|
||||
getPermissionStructure() {
|
||||
return {
|
||||
diary: { label: 'Trainingstagebuch', actions: ['read', 'write', 'delete'] },
|
||||
@@ -384,10 +131,439 @@ class PermissionService {
|
||||
permissions: { label: 'Berechtigungsverwaltung', actions: ['read', 'write'] },
|
||||
approvals: { label: 'Freigaben (Mitgliedsanträge)', actions: ['read', 'write'] },
|
||||
mytischtennis_admin: { label: 'MyTischtennis Admin', actions: ['read', 'write'] },
|
||||
predefined_activities: { label: 'Vordefinierte Aktivitäten', actions: ['read', 'write', 'delete'] }
|
||||
predefined_activities: { label: 'Vordefinierte Aktivitäten', actions: ['read', 'write', 'delete'] },
|
||||
};
|
||||
}
|
||||
|
||||
getAvailableRoles() {
|
||||
return DEFAULT_ROLE_TEMPLATES.map((role) => ({
|
||||
value: role.roleKey,
|
||||
label: role.name,
|
||||
description: role.description,
|
||||
isSystemRole: true,
|
||||
}));
|
||||
}
|
||||
|
||||
isMissingRoleTableError(error) {
|
||||
return error?.original?.code === 'ER_NO_SUCH_TABLE'
|
||||
&& /club_roles|club_user_roles/.test(String(error?.original?.sqlMessage || ''));
|
||||
}
|
||||
|
||||
async ensureDefaultRoles(clubId) {
|
||||
const createdRoles = [];
|
||||
for (const template of DEFAULT_ROLE_TEMPLATES) {
|
||||
const [role] = await ClubRole.findOrCreate({
|
||||
where: { clubId, roleKey: template.roleKey },
|
||||
defaults: {
|
||||
clubId,
|
||||
roleKey: template.roleKey,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
permissions: template.permissions,
|
||||
isSystemRole: true,
|
||||
sortOrder: template.sortOrder,
|
||||
},
|
||||
});
|
||||
createdRoles.push(role);
|
||||
}
|
||||
return createdRoles;
|
||||
}
|
||||
|
||||
async getRoleAssignments(clubId, userIds = null) {
|
||||
const where = { clubId };
|
||||
if (Array.isArray(userIds)) {
|
||||
where.userId = userIds;
|
||||
}
|
||||
|
||||
return ClubUserRole.findAll({
|
||||
where,
|
||||
include: [{
|
||||
model: ClubRole,
|
||||
as: 'role',
|
||||
}],
|
||||
order: [
|
||||
['isPrimary', 'DESC'],
|
||||
[{ model: ClubRole, as: 'role' }, 'sortOrder', 'ASC'],
|
||||
[{ model: ClubRole, as: 'role' }, 'name', 'ASC'],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
buildLegacyPermissionPayload(userClub) {
|
||||
if (userClub.isOwner) {
|
||||
return {
|
||||
role: 'admin',
|
||||
roles: [{ roleKey: 'admin', name: 'Administrator', isPrimary: true, isSystemRole: true }],
|
||||
isOwner: true,
|
||||
isAdmin: true,
|
||||
permissions: cloneValue(ROLE_PERMISSIONS.admin),
|
||||
};
|
||||
}
|
||||
|
||||
const primaryRole = userClub.role || 'member';
|
||||
const effectivePermissions = this.mergePermissions(
|
||||
ROLE_PERMISSIONS[primaryRole] || ROLE_PERMISSIONS.member,
|
||||
userClub.permissions
|
||||
);
|
||||
|
||||
return {
|
||||
role: primaryRole,
|
||||
roles: [{
|
||||
roleKey: primaryRole,
|
||||
name: DEFAULT_ROLE_TEMPLATES.find((role) => role.roleKey === primaryRole)?.name || primaryRole,
|
||||
isPrimary: true,
|
||||
isSystemRole: true,
|
||||
}],
|
||||
isOwner: false,
|
||||
isAdmin: primaryRole === 'admin',
|
||||
permissions: effectivePermissions,
|
||||
};
|
||||
}
|
||||
|
||||
buildRolePermissionPayload(userClub, assignments) {
|
||||
if (userClub.isOwner) {
|
||||
return {
|
||||
role: 'admin',
|
||||
roles: [{ roleKey: 'admin', name: 'Administrator', isPrimary: true, isSystemRole: true }],
|
||||
isOwner: true,
|
||||
isAdmin: true,
|
||||
permissions: cloneValue(ROLE_PERMISSIONS.admin),
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedAssignments = assignments
|
||||
.filter((assignment) => assignment?.role)
|
||||
.map((assignment) => ({
|
||||
id: assignment.role.id,
|
||||
roleKey: assignment.role.roleKey,
|
||||
name: assignment.role.name,
|
||||
description: assignment.role.description,
|
||||
isSystemRole: Boolean(assignment.role.isSystemRole),
|
||||
isPrimary: Boolean(assignment.isPrimary),
|
||||
assignedAt: assignment.createdAt || null,
|
||||
assignmentUpdatedAt: assignment.updatedAt || null,
|
||||
roleCreatedAt: assignment.role.createdAt || null,
|
||||
roleUpdatedAt: assignment.role.updatedAt || null,
|
||||
permissions: normalizePermissions(assignment.role.permissions),
|
||||
}));
|
||||
|
||||
if (normalizedAssignments.length === 0) {
|
||||
return this.buildLegacyPermissionPayload(userClub);
|
||||
}
|
||||
|
||||
const primaryRole = normalizedAssignments.find((role) => role.isPrimary) || normalizedAssignments[0];
|
||||
const rolePermissions = normalizedAssignments.reduce(
|
||||
(accumulator, role) => this.mergePermissions(accumulator, role.permissions),
|
||||
{}
|
||||
);
|
||||
const effectivePermissions = this.mergePermissions(rolePermissions, userClub.permissions);
|
||||
|
||||
return {
|
||||
role: normalizedAssignments.some((role) => role.roleKey === 'admin') ? 'admin' : primaryRole.roleKey,
|
||||
roles: normalizedAssignments.map(({ permissions, ...role }) => role),
|
||||
isOwner: false,
|
||||
isAdmin: normalizedAssignments.some((role) => role.roleKey === 'admin'),
|
||||
permissions: effectivePermissions,
|
||||
};
|
||||
}
|
||||
|
||||
async getUserClubPermissions(userId, clubId) {
|
||||
const userClub = await UserClub.findOne({
|
||||
where: { userId, clubId, approved: true },
|
||||
});
|
||||
|
||||
if (!userClub) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureDefaultRoles(clubId);
|
||||
const assignments = await this.getRoleAssignments(clubId, [userId]);
|
||||
return this.buildRolePermissionPayload(userClub, assignments);
|
||||
} catch (error) {
|
||||
if (this.isMissingRoleTableError(error)) {
|
||||
return this.buildLegacyPermissionPayload(userClub);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async hasPermission(userId, clubId, resource, action) {
|
||||
const userPermissions = await this.getUserClubPermissions(userId, clubId);
|
||||
if (!userPermissions) {
|
||||
return false;
|
||||
}
|
||||
if (userPermissions.isOwner) {
|
||||
return true;
|
||||
}
|
||||
if (resource === 'mytischtennis') {
|
||||
return true;
|
||||
}
|
||||
return userPermissions.permissions?.[resource]?.[action] === true;
|
||||
}
|
||||
|
||||
async setUserRole(userId, clubId, roleKey, updatedByUserId) {
|
||||
await this.ensureDefaultRoles(clubId);
|
||||
const role = await ClubRole.findOne({ where: { clubId, roleKey } });
|
||||
if (!role) {
|
||||
throw new Error('Ungültige Rolle');
|
||||
}
|
||||
return this.setUserRoles(userId, clubId, [role.id], updatedByUserId);
|
||||
}
|
||||
|
||||
async setUserRoles(userId, clubId, roleIds, updatedByUserId) {
|
||||
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
|
||||
if (!canManagePermissions) {
|
||||
throw new Error('Keine Berechtigung zum Ändern von Rollen');
|
||||
}
|
||||
|
||||
const targetUserClub = await UserClub.findOne({ where: { userId, clubId } });
|
||||
if (!targetUserClub) {
|
||||
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
|
||||
}
|
||||
if (targetUserClub.isOwner) {
|
||||
throw new Error('Die Rollen des Club-Erstellers können nicht geändert werden');
|
||||
}
|
||||
|
||||
await this.ensureDefaultRoles(clubId);
|
||||
const normalizedRoleIds = [...new Set((roleIds || []).map((id) => Number(id)).filter(Boolean))];
|
||||
const roles = normalizedRoleIds.length > 0
|
||||
? await ClubRole.findAll({ where: { clubId, id: normalizedRoleIds } })
|
||||
: [];
|
||||
|
||||
if (roles.length !== normalizedRoleIds.length) {
|
||||
throw new Error('Mindestens eine Rolle gehört nicht zu diesem Verein');
|
||||
}
|
||||
|
||||
try {
|
||||
await ClubUserRole.destroy({ where: { clubId, userId } });
|
||||
if (roles.length > 0) {
|
||||
await ClubUserRole.bulkCreate(roles.map((role, index) => ({
|
||||
clubId,
|
||||
userId,
|
||||
clubRoleId: role.id,
|
||||
isPrimary: index === 0,
|
||||
})));
|
||||
}
|
||||
await targetUserClub.update({ role: roles[0]?.roleKey || 'member' });
|
||||
} catch (error) {
|
||||
if (this.isMissingRoleTableError(error)) {
|
||||
await targetUserClub.update({ role: roles[0]?.roleKey || 'member' });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: 'Rollen erfolgreich aktualisiert' };
|
||||
}
|
||||
|
||||
async setCustomPermissions(userId, clubId, customPermissions, updatedByUserId) {
|
||||
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
|
||||
if (!canManagePermissions) {
|
||||
throw new Error('Keine Berechtigung zum Ändern von Berechtigungen');
|
||||
}
|
||||
|
||||
const targetUserClub = await UserClub.findOne({ where: { userId, clubId } });
|
||||
if (!targetUserClub) {
|
||||
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
|
||||
}
|
||||
if (targetUserClub.isOwner) {
|
||||
throw new Error('Die Berechtigungen des Club-Erstellers können nicht geändert werden');
|
||||
}
|
||||
|
||||
await targetUserClub.update({ permissions: customPermissions });
|
||||
return { success: true, message: 'Berechtigungen erfolgreich aktualisiert' };
|
||||
}
|
||||
|
||||
async setUserStatus(userId, clubId, approved, updatedByUserId) {
|
||||
const canManagePermissions = await this.hasPermission(updatedByUserId, clubId, 'permissions', 'write');
|
||||
if (!canManagePermissions) {
|
||||
throw new Error('Keine Berechtigung zum Ändern des Status');
|
||||
}
|
||||
|
||||
const targetUserClub = await UserClub.findOne({ where: { userId, clubId } });
|
||||
if (!targetUserClub) {
|
||||
throw new Error('Benutzer ist kein Mitglied dieses Clubs');
|
||||
}
|
||||
if (targetUserClub.isOwner) {
|
||||
throw new Error('Der Status des Club-Erstellers kann nicht geändert werden');
|
||||
}
|
||||
|
||||
await targetUserClub.update({ approved });
|
||||
return { success: true, message: approved ? 'Benutzer erfolgreich aktiviert' : 'Benutzer erfolgreich deaktiviert' };
|
||||
}
|
||||
|
||||
async getClubMembersWithPermissions(clubId, requestingUserId) {
|
||||
const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read');
|
||||
if (!canReadPermissions) {
|
||||
throw new Error('Keine Berechtigung zum Anzeigen von Berechtigungen');
|
||||
}
|
||||
|
||||
const userClubs = await UserClub.findAll({
|
||||
where: { clubId },
|
||||
include: [{ model: User, as: 'user', attributes: ['id', 'email'] }],
|
||||
order: [[{ model: User, as: 'user' }, 'email', 'ASC']],
|
||||
});
|
||||
|
||||
try {
|
||||
await this.ensureDefaultRoles(clubId);
|
||||
const assignments = await this.getRoleAssignments(clubId, userClubs.map((entry) => entry.userId));
|
||||
const assignmentsByUserId = assignments.reduce((accumulator, assignment) => {
|
||||
const key = Number(assignment.userId);
|
||||
if (!accumulator[key]) {
|
||||
accumulator[key] = [];
|
||||
}
|
||||
accumulator[key].push(assignment);
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
return userClubs.map((userClub) => {
|
||||
const payload = this.buildRolePermissionPayload(userClub, assignmentsByUserId[Number(userClub.userId)] || []);
|
||||
return {
|
||||
userId: userClub.userId,
|
||||
user: userClub.user,
|
||||
role: payload.role,
|
||||
roles: payload.roles,
|
||||
isAdmin: payload.isAdmin,
|
||||
isOwner: userClub.isOwner,
|
||||
approved: userClub.approved,
|
||||
createdAt: userClub.createdAt,
|
||||
updatedAt: userClub.updatedAt,
|
||||
permissions: normalizePermissions(userClub.permissions),
|
||||
effectivePermissions: payload.permissions,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isMissingRoleTableError(error)) {
|
||||
return userClubs.map((userClub) => {
|
||||
const payload = this.buildLegacyPermissionPayload(userClub);
|
||||
return {
|
||||
userId: userClub.userId,
|
||||
user: userClub.user,
|
||||
role: payload.role,
|
||||
roles: payload.roles,
|
||||
isAdmin: payload.isAdmin,
|
||||
isOwner: userClub.isOwner,
|
||||
approved: userClub.approved,
|
||||
createdAt: userClub.createdAt,
|
||||
updatedAt: userClub.updatedAt,
|
||||
permissions: normalizePermissions(userClub.permissions),
|
||||
effectivePermissions: payload.permissions,
|
||||
};
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getClubRoles(clubId, requestingUserId) {
|
||||
const canReadPermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'read');
|
||||
if (!canReadPermissions) {
|
||||
throw new Error('Keine Berechtigung zum Anzeigen von Rollen');
|
||||
}
|
||||
|
||||
await this.ensureDefaultRoles(clubId);
|
||||
return ClubRole.findAll({
|
||||
where: { clubId },
|
||||
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
|
||||
});
|
||||
}
|
||||
|
||||
async createClubRole(clubId, payload, requestingUserId) {
|
||||
const canManagePermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'write');
|
||||
if (!canManagePermissions) {
|
||||
throw new Error('Keine Berechtigung zum Anlegen von Rollen');
|
||||
}
|
||||
|
||||
const baseKey = slugifyRoleKey(payload.roleKey || payload.name);
|
||||
const existingKeys = new Set((await ClubRole.findAll({
|
||||
where: { clubId },
|
||||
attributes: ['roleKey'],
|
||||
})).map((role) => role.roleKey));
|
||||
let roleKey = baseKey;
|
||||
let suffix = 2;
|
||||
while (existingKeys.has(roleKey)) {
|
||||
roleKey = `${baseKey}_${suffix++}`;
|
||||
}
|
||||
|
||||
const role = await ClubRole.create({
|
||||
clubId,
|
||||
roleKey,
|
||||
name: String(payload.name || '').trim(),
|
||||
description: String(payload.description || '').trim() || null,
|
||||
permissions: normalizePermissions(payload.permissions),
|
||||
isSystemRole: false,
|
||||
sortOrder: Number(payload.sortOrder) || 100,
|
||||
});
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
async updateClubRole(clubId, roleId, payload, requestingUserId) {
|
||||
const canManagePermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'write');
|
||||
if (!canManagePermissions) {
|
||||
throw new Error('Keine Berechtigung zum Ändern von Rollen');
|
||||
}
|
||||
|
||||
const role = await ClubRole.findOne({ where: { id: roleId, clubId } });
|
||||
if (!role) {
|
||||
throw new Error('Rolle nicht gefunden');
|
||||
}
|
||||
|
||||
await role.update({
|
||||
name: String(payload.name || role.name).trim(),
|
||||
description: payload.description === undefined ? role.description : (String(payload.description || '').trim() || null),
|
||||
permissions: payload.permissions === undefined ? role.permissions : normalizePermissions(payload.permissions),
|
||||
sortOrder: payload.sortOrder === undefined ? role.sortOrder : Number(payload.sortOrder) || 100,
|
||||
});
|
||||
|
||||
return role;
|
||||
}
|
||||
|
||||
async deleteClubRole(clubId, roleId, requestingUserId) {
|
||||
const canManagePermissions = await this.hasPermission(requestingUserId, clubId, 'permissions', 'write');
|
||||
if (!canManagePermissions) {
|
||||
throw new Error('Keine Berechtigung zum Löschen von Rollen');
|
||||
}
|
||||
|
||||
const role = await ClubRole.findOne({ where: { id: roleId, clubId } });
|
||||
if (!role) {
|
||||
throw new Error('Rolle nicht gefunden');
|
||||
}
|
||||
if (role.isSystemRole) {
|
||||
throw new Error('Systemrollen können nicht gelöscht werden');
|
||||
}
|
||||
|
||||
await ClubUserRole.destroy({ where: { clubId, clubRoleId: roleId } });
|
||||
await role.destroy();
|
||||
return { success: true, message: 'Rolle erfolgreich gelöscht' };
|
||||
}
|
||||
|
||||
async setClubOwner(userId, clubId) {
|
||||
const userClub = await UserClub.findOne({ where: { userId, clubId } });
|
||||
if (!userClub) {
|
||||
throw new Error('UserClub relationship not found');
|
||||
}
|
||||
|
||||
await userClub.update({ isOwner: true, role: 'admin', approved: true });
|
||||
try {
|
||||
await this.ensureDefaultRoles(clubId);
|
||||
const adminRole = await ClubRole.findOne({ where: { clubId, roleKey: 'admin' } });
|
||||
if (adminRole) {
|
||||
await ClubUserRole.destroy({ where: { clubId, userId } });
|
||||
await ClubUserRole.create({
|
||||
clubId,
|
||||
userId,
|
||||
clubRoleId: adminRole.id,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.isMissingRoleTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new PermissionService();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user