Compare commits
5 Commits
111b37b287
...
ac97332e6f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac97332e6f | ||
|
|
90e6c2f9f6 | ||
|
|
dd91c07694 | ||
|
|
302ed20a9b | ||
|
|
542fae089c |
@@ -19,7 +19,7 @@ export const createClubCalendarEvent = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const event = await calendarEventService.createClubEvent(userToken, clubId, req.body);
|
||||
const event = await calendarEventService.createClubEvent(userToken, clubId, req.body, req.user?.id || null);
|
||||
res.status(201).json(event);
|
||||
} catch (error) {
|
||||
console.error('[createClubCalendarEvent] - Error:', error);
|
||||
@@ -28,6 +28,19 @@ export const createClubCalendarEvent = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubCalendarEvent = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, eventId } = req.params;
|
||||
const event = await calendarEventService.updateClubEvent(userToken, clubId, eventId, req.body, req.user?.id || null);
|
||||
res.status(200).json(event);
|
||||
} catch (error) {
|
||||
console.error('[updateClubCalendarEvent] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Speichern des Kalender-Events');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubCalendarEvent = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
|
||||
93
backend/controllers/clubAccountController.js
Normal file
93
backend/controllers/clubAccountController.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import clubAccountService from '../services/clubAccountService.js';
|
||||
|
||||
class ClubAccountController {
|
||||
async listClubAccounts(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = await clubAccountService.listClubAccounts(Number(clubId));
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
console.error('[listClubAccounts] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Konten konnten nicht geladen werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async createClubAccount(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const account = await clubAccountService.createClubAccount(Number(clubId), req.body || {});
|
||||
res.status(201).json({ account });
|
||||
} catch (error) {
|
||||
console.error('[createClubAccount] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateClubAccount(req, res) {
|
||||
try {
|
||||
const { clubId, accountId } = req.params;
|
||||
const account = await clubAccountService.updateClubAccount(Number(clubId), Number(accountId), req.body || {});
|
||||
res.json({ account });
|
||||
} catch (error) {
|
||||
console.error('[updateClubAccount] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateClubAccountStatus(req, res) {
|
||||
try {
|
||||
const { clubId, accountId } = req.params;
|
||||
const account = await clubAccountService.updateClubAccountStatus(Number(clubId), Number(accountId), String(req.body?.status || ''));
|
||||
res.json({ account });
|
||||
} catch (error) {
|
||||
console.error('[updateClubAccountStatus] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Kontostatus konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteClubAccount(req, res) {
|
||||
try {
|
||||
const { clubId, accountId } = req.params;
|
||||
await clubAccountService.deleteClubAccount(Number(clubId), Number(accountId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubAccount] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async createTransaction(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const transaction = await clubAccountService.createTransaction(Number(clubId), req.user?.id || null, req.body || {});
|
||||
res.status(201).json({ transaction });
|
||||
} catch (error) {
|
||||
console.error('[createClubAccountTransaction] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateTransaction(req, res) {
|
||||
try {
|
||||
const { clubId, transactionId } = req.params;
|
||||
const transaction = await clubAccountService.updateTransaction(Number(clubId), Number(transactionId), req.body || {});
|
||||
res.json({ transaction });
|
||||
} catch (error) {
|
||||
console.error('[updateClubAccountTransaction] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTransaction(req, res) {
|
||||
try {
|
||||
const { clubId, transactionId } = req.params;
|
||||
await clubAccountService.deleteTransaction(Number(clubId), Number(transactionId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubAccountTransaction] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubAccountController();
|
||||
19
backend/controllers/clubArchiveController.js
Normal file
19
backend/controllers/clubArchiveController.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import clubArchiveService from '../services/clubArchiveService.js';
|
||||
|
||||
class ClubArchiveController {
|
||||
async getClubArchive(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const archive = await clubArchiveService.getClubArchive(clubId);
|
||||
res.json(archive);
|
||||
} catch (error) {
|
||||
console.error('[getClubArchive] - Error:', error);
|
||||
if (error?.status) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Fehler beim Laden des Vereinsarchivs' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubArchiveController();
|
||||
132
backend/controllers/clubCommunicationController.js
Normal file
132
backend/controllers/clubCommunicationController.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import clubCommunicationService from '../services/clubCommunicationService.js';
|
||||
|
||||
function handleError(res, scope, error) {
|
||||
console.error(`[${scope}] - Error:`, error);
|
||||
res.status(error?.status || 500).json({
|
||||
error: error?.message || 'internalerror',
|
||||
});
|
||||
}
|
||||
|
||||
const clubCommunicationController = {
|
||||
async list(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = await clubCommunicationService.listClubCommunication(Number(clubId));
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
handleError(res, 'listClubCommunication', error);
|
||||
}
|
||||
},
|
||||
|
||||
async createThread(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const thread = await clubCommunicationService.createThread(Number(clubId), req.user?.id || null, req.body || {});
|
||||
res.status(201).json({ thread });
|
||||
} catch (error) {
|
||||
handleError(res, 'createClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateThread(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
const thread = await clubCommunicationService.updateThread(Number(clubId), Number(threadId), req.body || {});
|
||||
res.json({ thread });
|
||||
} catch (error) {
|
||||
handleError(res, 'updateClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteThread(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
await clubCommunicationService.deleteThread(Number(clubId), Number(threadId));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
handleError(res, 'deleteClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async addMessage(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
const message = await clubCommunicationService.addMessage(Number(clubId), Number(threadId), req.user?.id || null, req.body || {});
|
||||
res.status(201).json({ message });
|
||||
} catch (error) {
|
||||
handleError(res, 'addClubCommunicationMessage', error);
|
||||
}
|
||||
},
|
||||
|
||||
async sendThread(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
const thread = await clubCommunicationService.sendThread(Number(clubId), Number(threadId), req.user?.id || null);
|
||||
res.json({ thread });
|
||||
} catch (error) {
|
||||
handleError(res, 'sendClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async createGroup(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const group = await clubCommunicationService.createGroup(Number(clubId), req.body || {});
|
||||
res.status(201).json({ group });
|
||||
} catch (error) {
|
||||
handleError(res, 'createClubDistributionGroup', error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateGroup(req, res) {
|
||||
try {
|
||||
const { clubId, groupId } = req.params;
|
||||
const group = await clubCommunicationService.updateGroup(Number(clubId), Number(groupId), req.body || {});
|
||||
res.json({ group });
|
||||
} catch (error) {
|
||||
handleError(res, 'updateClubDistributionGroup', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteGroup(req, res) {
|
||||
try {
|
||||
const { clubId, groupId } = req.params;
|
||||
await clubCommunicationService.deleteGroup(Number(clubId), Number(groupId));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
handleError(res, 'deleteClubDistributionGroup', error);
|
||||
}
|
||||
},
|
||||
|
||||
async createTemplate(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const template = await clubCommunicationService.createTemplate(Number(clubId), req.body || {});
|
||||
res.status(201).json({ template });
|
||||
} catch (error) {
|
||||
handleError(res, 'createClubCommunicationTemplate', error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateTemplate(req, res) {
|
||||
try {
|
||||
const { clubId, templateId } = req.params;
|
||||
const template = await clubCommunicationService.updateTemplate(Number(clubId), Number(templateId), req.body || {});
|
||||
res.json({ template });
|
||||
} catch (error) {
|
||||
handleError(res, 'updateClubCommunicationTemplate', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteTemplate(req, res) {
|
||||
try {
|
||||
const { clubId, templateId } = req.params;
|
||||
await clubCommunicationService.deleteTemplate(Number(clubId), Number(templateId));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
handleError(res, 'deleteClubCommunicationTemplate', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default clubCommunicationController;
|
||||
671
backend/controllers/clubDashboardController.js
Normal file
671
backend/controllers/clubDashboardController.js
Normal file
@@ -0,0 +1,671 @@
|
||||
import { Op } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import {
|
||||
CalendarEvent,
|
||||
ClubCommunicationThread,
|
||||
ClubInvoice,
|
||||
ClubPaymentClaim,
|
||||
ClubRequest,
|
||||
ClubSepaMandate,
|
||||
ClubTask,
|
||||
Match,
|
||||
Member,
|
||||
TrainingGroup,
|
||||
} from '../models/index.js';
|
||||
import clubArchiveService from '../services/clubArchiveService.js';
|
||||
import { hasClubPaymentClaimPaidAmountCentsColumn } from '../services/clubPaymentClaimCompatibility.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorUtils.js';
|
||||
|
||||
function formatRequestWorkflowStage(stage) {
|
||||
return {
|
||||
contact_replied: 'Kontakt beantwortet',
|
||||
trial_training_scheduled: 'Probetraining terminiert',
|
||||
trial_training_feedback_recorded: 'Probetraining nachbereitet',
|
||||
membership_reviewed: 'Mitgliedsanfrage geprüft',
|
||||
admission_prepared: 'Aufnahme vorbereitet',
|
||||
member_record_created: 'Mitglied angelegt',
|
||||
sepa_pending: 'SEPA ausstehend',
|
||||
onboarding_completed: 'Onboarding abgeschlossen',
|
||||
sponsoring_contacted: 'Sponsoring kontaktiert',
|
||||
sponsoring_offer_prepared: 'Sponsoringangebot vorbereitet',
|
||||
sponsoring_followed_up: 'Sponsoring nachgefasst',
|
||||
}[stage] || stage;
|
||||
}
|
||||
|
||||
function formatTaskType(taskType) {
|
||||
return {
|
||||
request_contact_reply: 'Kontaktanfrage beantworten',
|
||||
request_schedule_trial_training: 'Probetraining organisieren',
|
||||
request_trial_training_follow_up: 'Probetraining nachbereiten',
|
||||
request_membership_review: 'Mitgliedsanfrage prüfen',
|
||||
membership_prepare_admission: 'Aufnahme vorbereiten',
|
||||
membership_create_member_record: 'Mitglied anlegen',
|
||||
membership_collect_sepa_mandate: 'SEPA organisieren',
|
||||
membership_assign_fee: 'Beitrag zuordnen',
|
||||
request_sponsoring_reply: 'Sponsoring nachfassen',
|
||||
sponsoring_prepare_offer: 'Sponsoringangebot vorbereiten',
|
||||
sponsoring_follow_up: 'Sponsoring nachfassen',
|
||||
document_review_required: 'Dokument prüfen',
|
||||
sponsor_contract_renewal: 'Sponsoring verlängern',
|
||||
member_missing_email: 'E-Mail ergänzen',
|
||||
member_missing_birthdate: 'Geburtsdatum ergänzen',
|
||||
member_missing_sepa_mandate: 'SEPA-Mandat einholen',
|
||||
payment_claim_due_soon: 'Fällige Zahlung vorbereiten',
|
||||
payment_claim_overdue: 'Überfällige Zahlung nachfassen',
|
||||
payment_claim_reminder: 'Mahnstufe prüfen',
|
||||
calendar_event_prepare: 'Termin vorbereiten',
|
||||
calendar_event_deadline_check: 'Terminfrist prüfen',
|
||||
}[taskType] || taskType || 'Freie Aufgabe';
|
||||
}
|
||||
|
||||
function formatCommunicationStatus(status) {
|
||||
return {
|
||||
draft: 'Entwurf',
|
||||
scheduled: 'Geplant',
|
||||
sent: 'Versendet',
|
||||
archived: 'Archiviert',
|
||||
}[status] || status || 'Status';
|
||||
}
|
||||
|
||||
function formatInvoiceStatus(status) {
|
||||
return {
|
||||
draft: 'Entwurf',
|
||||
issued: 'Gestellt',
|
||||
partially_paid: 'Teilbezahlt',
|
||||
paid: 'Bezahlt',
|
||||
cancelled: 'Storniert',
|
||||
archived: 'Archiviert',
|
||||
}[status] || status || 'Status';
|
||||
}
|
||||
|
||||
function formatEventDateRange(event) {
|
||||
if (!event?.startDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' });
|
||||
const start = formatter.format(new Date(event.startDate));
|
||||
const end = event.endDate ? formatter.format(new Date(event.endDate)) : start;
|
||||
return start === end ? start : `${start} bis ${end}`;
|
||||
}
|
||||
|
||||
function formatDate(value, options = { dateStyle: 'medium' }) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('de-DE', options).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(value).slice(0, 5);
|
||||
}
|
||||
|
||||
function formatWeekday(weekday) {
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][Number(weekday)] || 'Unbekannt';
|
||||
}
|
||||
|
||||
function formatConfiguredTrainingLabel(entry) {
|
||||
const start = formatTime(entry.startTime);
|
||||
const end = formatTime(entry.endTime);
|
||||
const timeRange = start && end ? `${start} bis ${end} Uhr` : start ? `${start} Uhr` : null;
|
||||
|
||||
return [entry.groupName, formatWeekday(entry.weekday), timeRange].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function formatMatchLabel(match) {
|
||||
const date = formatDate(match.date);
|
||||
const time = formatTime(match.time);
|
||||
const homeTeam = match.homeTeam?.name || 'Heimteam';
|
||||
const guestTeam = match.guestTeam?.name || 'Gastteam';
|
||||
const league = match.leagueDetails?.name || null;
|
||||
|
||||
return [
|
||||
`${homeTeam} gegen ${guestTeam}`,
|
||||
date,
|
||||
time ? `${time} Uhr` : null,
|
||||
league,
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function createDashboardItem(label, to, extra = {}) {
|
||||
return { label, to, ...extra };
|
||||
}
|
||||
|
||||
function buildMemberRoute(memberId, scope = 'active', extraQuery = {}) {
|
||||
return {
|
||||
path: '/members',
|
||||
query: {
|
||||
scope,
|
||||
memberId: String(memberId),
|
||||
mode: 'edit',
|
||||
...extraQuery,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequestRoute(requestId, status = '') {
|
||||
const query = { requestId: String(requestId) };
|
||||
if (status) {
|
||||
query.status = status;
|
||||
}
|
||||
return {
|
||||
path: '/club-requests',
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskRoute(taskId, status = '') {
|
||||
const query = { taskId: String(taskId) };
|
||||
if (status) {
|
||||
query.status = status;
|
||||
}
|
||||
return {
|
||||
path: '/club-tasks',
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAvailableTables() {
|
||||
const tables = await sequelize.getQueryInterface().showAllTables();
|
||||
return new Set(
|
||||
tables
|
||||
.map((table) => (typeof table === 'string' ? table : Object.values(table || {})[0]))
|
||||
.filter(Boolean)
|
||||
.map((table) => String(table).toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
async function loadOptionalTableData(availableTables, tableName, loader, fallbackValue = []) {
|
||||
if (!availableTables.has(String(tableName).toLowerCase())) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return loader();
|
||||
}
|
||||
|
||||
function countMissingMemberFields(members) {
|
||||
const missing = {
|
||||
email: 0,
|
||||
birthDate: 0,
|
||||
};
|
||||
|
||||
for (const member of members) {
|
||||
if (!String(member.email || '').trim()) {
|
||||
missing.email += 1;
|
||||
}
|
||||
if (!String(member.birthDate || '').trim()) {
|
||||
missing.birthDate += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
function toNextOccurrenceDate(weekday, startTime) {
|
||||
const now = new Date();
|
||||
const result = new Date(now);
|
||||
const targetWeekday = Number(weekday);
|
||||
const daysUntilWeekday = (targetWeekday - result.getDay() + 7) % 7;
|
||||
result.setDate(result.getDate() + daysUntilWeekday);
|
||||
|
||||
const [hours = '0', minutes = '0'] = String(startTime || '00:00').split(':');
|
||||
result.setHours(Number(hours), Number(minutes), 0, 0);
|
||||
|
||||
if (result < now) {
|
||||
result.setDate(result.getDate() + 7);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildUpcomingTrainingSlots(groups, limit = 5) {
|
||||
return groups
|
||||
.flatMap((group) => (Array.isArray(group.trainingTimes) ? group.trainingTimes.map((time) => ({
|
||||
id: time.id,
|
||||
weekday: time.weekday,
|
||||
startTime: time.startTime,
|
||||
endTime: time.endTime,
|
||||
sortOrder: time.sortOrder,
|
||||
groupName: group.name,
|
||||
nextOccurrence: toNextOccurrenceDate(time.weekday, time.startTime),
|
||||
})) : []))
|
||||
.sort((left, right) => {
|
||||
const timeDiff = left.nextOccurrence.getTime() - right.nextOccurrence.getTime();
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return String(left.groupName || '').localeCompare(String(right.groupName || ''));
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export const getClubDashboard = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const currentUserId = Number(req.user?.id) || null;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayIso = today.toISOString().slice(0, 10);
|
||||
const availableTables = await loadAvailableTables();
|
||||
const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn();
|
||||
|
||||
const [
|
||||
requests,
|
||||
tasks,
|
||||
members,
|
||||
mandates,
|
||||
paymentClaims,
|
||||
upcomingEvents,
|
||||
trainingGroups,
|
||||
upcomingMatches,
|
||||
communicationThreads,
|
||||
invoices,
|
||||
archive,
|
||||
] = await Promise.all([
|
||||
loadOptionalTableData(availableTables, 'club_requests', () => ClubRequest.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.notIn]: ['archived'] },
|
||||
},
|
||||
order: [['receivedAt', 'DESC']],
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'club_tasks', () => ClubTask.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.notIn]: ['archived'] },
|
||||
},
|
||||
order: [['dueAt', 'ASC'], ['updatedAt', 'DESC']],
|
||||
})),
|
||||
Member.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
active: true,
|
||||
},
|
||||
order: [['createdAt', 'DESC']],
|
||||
}),
|
||||
loadOptionalTableData(availableTables, 'club_sepa_mandates', () => ClubSepaMandate.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active',
|
||||
revokedAt: null,
|
||||
memberId: { [Op.ne]: null },
|
||||
},
|
||||
attributes: ['memberId'],
|
||||
})),
|
||||
hasPaidAmountCentsColumn
|
||||
? loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
},
|
||||
order: [['dueOn', 'ASC']],
|
||||
}))
|
||||
: Promise.resolve([]),
|
||||
loadOptionalTableData(availableTables, 'calendar_events', () => CalendarEvent.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
endDate: { [Op.gte]: todayIso },
|
||||
},
|
||||
order: [['startDate', 'ASC']],
|
||||
limit: 5,
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'training_group', () => TrainingGroup.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
association: 'trainingTimes',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [['isPreset', 'DESC'], ['sortOrder', 'ASC'], ['name', 'ASC']],
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'match', () => Match.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
date: { [Op.gte]: today },
|
||||
},
|
||||
include: [
|
||||
{ association: 'homeTeam', attributes: ['id', 'name'] },
|
||||
{ association: 'guestTeam', attributes: ['id', 'name'] },
|
||||
{ association: 'leagueDetails', attributes: ['id', 'name'] },
|
||||
],
|
||||
order: [['date', 'ASC'], ['time', 'ASC']],
|
||||
limit: 5,
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'club_communication_threads', () => ClubCommunicationThread.findAll({
|
||||
where: { clubId },
|
||||
attributes: ['id', 'subject', 'status', 'threadType', 'scheduledAt', 'sentAt', 'updatedAt'],
|
||||
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
|
||||
limit: 8,
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'club_invoices', () => ClubInvoice.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
archivedAt: null,
|
||||
},
|
||||
attributes: ['id', 'invoiceNumber', 'invoiceDirection', 'invoiceType', 'status', 'dueOn', 'grossAmountCents', 'currencyCode', 'updatedAt'],
|
||||
order: [['updatedAt', 'DESC'], ['dueOn', 'ASC']],
|
||||
limit: 8,
|
||||
})),
|
||||
clubArchiveService.getClubArchive(clubId),
|
||||
]);
|
||||
|
||||
const visibleDashboardTasks = tasks.filter((task) => !task.assignedUserId || Number(task.assignedUserId) === currentUserId);
|
||||
const membersById = new Map(members.map((member) => [Number(member.id), member]));
|
||||
const paymentClaimsById = new Map(paymentClaims.map((claim) => [Number(claim.id), claim]));
|
||||
const missingFields = countMissingMemberFields(members);
|
||||
const openTasks = visibleDashboardTasks.filter((task) => task.status === 'open');
|
||||
const inProgressTasks = visibleDashboardTasks.filter((task) => task.status === 'in_progress');
|
||||
const automatedTasks = visibleDashboardTasks.filter((task) => Boolean(task.automationKey));
|
||||
const automatedOpenTasks = automatedTasks.filter((task) => ['open', 'in_progress', 'waiting'].includes(task.status));
|
||||
const overdueTaskCount = visibleDashboardTasks.filter((task) => {
|
||||
if (!task.dueAt || ['done', 'cancelled', 'archived'].includes(task.status)) {
|
||||
return false;
|
||||
}
|
||||
return new Date(task.dueAt) < today;
|
||||
}).length;
|
||||
const memberIdsWithMandate = new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
|
||||
const missingMandateCount = members.filter((member) => !memberIdsWithMandate.has(Number(member.id))).length;
|
||||
const openRequestCount = requests.filter((request) => request.status === 'open').length;
|
||||
const inProgressRequestCount = requests.filter((request) => request.status === 'in_progress').length;
|
||||
const trialTrainingCount = requests.filter((request) => request.requestType === 'trial_training' && request.status !== 'archived').length;
|
||||
const workflowStageCounts = requests.reduce((accumulator, request) => {
|
||||
if (!request.workflowStage) return accumulator;
|
||||
accumulator[request.workflowStage] = (accumulator[request.workflowStage] || 0) + 1;
|
||||
return accumulator;
|
||||
}, {});
|
||||
const onboardingCount =
|
||||
(workflowStageCounts.membership_reviewed || 0) +
|
||||
(workflowStageCounts.admission_prepared || 0) +
|
||||
(workflowStageCounts.member_record_created || 0) +
|
||||
(workflowStageCounts.sepa_pending || 0);
|
||||
const duePaymentCount = paymentClaims.filter((claim) => claim.status === 'open').length;
|
||||
const reminderCount = paymentClaims.filter((claim) => Number(claim.reminderLevel || 0) > 0).length;
|
||||
const openInvoices = invoices.filter((invoice) => ['draft', 'issued', 'partially_paid'].includes(invoice.status));
|
||||
const outgoingOpenInvoices = openInvoices.filter((invoice) => invoice.invoiceDirection === 'outgoing');
|
||||
const incomingOpenInvoices = openInvoices.filter((invoice) => invoice.invoiceDirection === 'incoming');
|
||||
const communicationDraftCount = communicationThreads.filter((thread) => thread.status === 'draft').length;
|
||||
const communicationScheduledCount = communicationThreads.filter((thread) => thread.status === 'scheduled').length;
|
||||
const communicationSentCount = communicationThreads.filter((thread) => thread.status === 'sent').length;
|
||||
const recentMembers = members.slice(0, 4);
|
||||
const upcomingTrainings = buildUpcomingTrainingSlots(trainingGroups);
|
||||
const paidRatio = paymentClaims.length === 0
|
||||
? null
|
||||
: Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
((paymentClaims.length - duePaymentCount) / paymentClaims.length) * 100
|
||||
)
|
||||
);
|
||||
|
||||
function taskDetailTarget(task) {
|
||||
if (task.relatedEntityType === 'member' && task.relatedEntityId) {
|
||||
return buildMemberRoute(task.relatedEntityId, 'active');
|
||||
}
|
||||
|
||||
if (task.relatedEntityType === 'club_request' && task.relatedEntityId) {
|
||||
const request = requests.find((entry) => Number(entry.id) === Number(task.relatedEntityId));
|
||||
return buildRequestRoute(task.relatedEntityId, request?.status || '');
|
||||
}
|
||||
|
||||
if (task.relatedEntityType === 'club_payment_claim' && task.relatedEntityId) {
|
||||
const claim = paymentClaimsById.get(Number(task.relatedEntityId));
|
||||
if (claim?.memberId) {
|
||||
return buildMemberRoute(claim.memberId, 'active');
|
||||
}
|
||||
}
|
||||
|
||||
return buildTaskRoute(task.id, task.status || '');
|
||||
}
|
||||
|
||||
function taskDashboardItem(task, label) {
|
||||
return createDashboardItem(label, taskDetailTarget(task), {
|
||||
isAssignedToCurrentUser: Boolean(task.assignedUserId) && Number(task.assignedUserId) === currentUserId,
|
||||
});
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'action-needed',
|
||||
title: 'Handlungsbedarf',
|
||||
cards: [
|
||||
{
|
||||
title: 'Neue Anfragen',
|
||||
value: `${openRequestCount + inProgressRequestCount}`,
|
||||
meta: trialTrainingCount > 0 ? `${trialTrainingCount} Probetrainings` : null,
|
||||
to: '/club-requests',
|
||||
accent: 'amber',
|
||||
items: [
|
||||
createDashboardItem(`${openRequestCount} offen`, '/club-requests'),
|
||||
createDashboardItem(`${inProgressRequestCount} in Bearbeitung`, '/club-requests'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Anfrage-Workflows',
|
||||
value: `${onboardingCount}`,
|
||||
meta: onboardingCount > 0 ? 'im Aufnahme- und Onboardingprozess' : 'Keine aktiven Onboarding-Fälle',
|
||||
to: '/club-requests',
|
||||
accent: 'green',
|
||||
items: [
|
||||
createDashboardItem(`${workflowStageCounts.trial_training_scheduled || 0} Probetrainings terminiert`, '/club-requests'),
|
||||
createDashboardItem(`${workflowStageCounts.membership_reviewed || 0} Mitgliedsanfragen geprüft`, '/club-requests'),
|
||||
createDashboardItem(`${workflowStageCounts.sepa_pending || 0} Fälle mit ausstehendem SEPA`, '/club-requests'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Offene Zahlungen',
|
||||
value: `${paymentClaims.length}`,
|
||||
meta: [
|
||||
duePaymentCount > 0 ? `${duePaymentCount} fällig offen` : null,
|
||||
reminderCount > 0 ? `${reminderCount} mit Mahnstufe` : null,
|
||||
].filter(Boolean).join(' · ') || 'Keine Mahnungen aktiv',
|
||||
to: '/club-payments',
|
||||
accent: 'red',
|
||||
items: paymentClaims.slice(0, 3).map((claim) => {
|
||||
const amount = `${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'}`;
|
||||
return createDashboardItem(
|
||||
`${amount} · ${claim.dueOn ? `fällig ${claim.dueOn}` : 'ohne Fälligkeit'}`,
|
||||
claim.memberId ? buildMemberRoute(claim.memberId, 'active') : '/club-payments'
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'Fehlende Daten',
|
||||
value: `${missingFields.email + missingFields.birthDate + missingMandateCount}`,
|
||||
to: '/members',
|
||||
accent: 'blue',
|
||||
items: [
|
||||
createDashboardItem(`${missingFields.email} Mitglieder ohne E-Mail`, { path: '/members', query: { scope: 'dataIncomplete' } }),
|
||||
createDashboardItem(`${missingFields.birthDate} Mitglieder ohne Geburtsdatum`, { path: '/members', query: { scope: 'dataIncomplete' } }),
|
||||
createDashboardItem(`${missingMandateCount} Mitglieder ohne SEPA-Mandat`, { path: '/members', query: { scope: 'dataIncomplete' } }),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Offene Aufgaben',
|
||||
value: `${openTasks.length + inProgressTasks.length}`,
|
||||
meta: overdueTaskCount > 0 ? `${overdueTaskCount} überfällig` : 'Keine überfälligen Aufgaben',
|
||||
to: '/club-tasks',
|
||||
accent: overdueTaskCount > 0 ? 'red' : 'green',
|
||||
items: [
|
||||
createDashboardItem(`${automatedOpenTasks.length} automatisch erzeugte Schritte`, '/club-tasks'),
|
||||
...visibleDashboardTasks.slice(0, 3).map((task) => taskDashboardItem(task, task.title)),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'operations-room',
|
||||
title: 'Kommunikation, Finanzen und Archiv',
|
||||
cards: [
|
||||
{
|
||||
title: 'Kommunikation',
|
||||
value: `${communicationThreads.length}`,
|
||||
meta: `${communicationDraftCount} Entwürfe, ${communicationScheduledCount} geplant, ${communicationSentCount} versendet`,
|
||||
to: '/club-communication',
|
||||
accent: 'blue',
|
||||
items: communicationThreads.slice(0, 3).map((thread) => createDashboardItem(
|
||||
`${thread.subject || 'Ohne Betreff'} · ${formatCommunicationStatus(thread.status)}`,
|
||||
'/club-communication'
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: 'Rechnungen',
|
||||
value: `${openInvoices.length}`,
|
||||
meta: `${outgoingOpenInvoices.length} ausgehend, ${incomingOpenInvoices.length} eingehend`,
|
||||
to: '/club-invoices',
|
||||
accent: 'amber',
|
||||
items: openInvoices.slice(0, 3).map((invoice) => createDashboardItem(
|
||||
`${invoice.invoiceNumber || `Rechnung #${invoice.id}`} · ${formatInvoiceStatus(invoice.status)}`,
|
||||
'/club-invoices'
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: 'Archiv',
|
||||
value: `${archive.summary.archivedRequests + archive.summary.archivedTasks + archive.summary.archivedClaims}`,
|
||||
meta: `${archive.summary.inactiveMembers} inaktive Mitglieder`,
|
||||
to: '/club-archive',
|
||||
accent: 'neutral',
|
||||
items: [
|
||||
createDashboardItem(`${archive.summary.archivedRequests} archivierte Anfragen`, '/club-archive'),
|
||||
createDashboardItem(`${archive.summary.archivedTasks} archivierte Aufgaben`, '/club-archive'),
|
||||
createDashboardItem(`${archive.summary.archivedClaims} archivierte Beitragsfälle`, '/club-archive'),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'appointments',
|
||||
title: 'Aktuelle Termine',
|
||||
cards: [
|
||||
{
|
||||
title: 'Nächste Trainings',
|
||||
value: `${upcomingTrainings.length}`,
|
||||
to: {
|
||||
path: '/club-settings',
|
||||
query: { tab: 'training-times' },
|
||||
},
|
||||
items: upcomingTrainings.map((training) => createDashboardItem(
|
||||
formatConfiguredTrainingLabel(training),
|
||||
{
|
||||
path: '/club-settings',
|
||||
query: { tab: 'training-times' },
|
||||
}
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: 'Nächste Spiele',
|
||||
value: `${upcomingMatches.length}`,
|
||||
to: '/schedule',
|
||||
items: upcomingMatches.map((match) => createDashboardItem(formatMatchLabel(match), '/schedule')),
|
||||
},
|
||||
{
|
||||
title: 'Kalendertermine',
|
||||
value: `${upcomingEvents.length}`,
|
||||
to: '/calendar',
|
||||
items: upcomingEvents.map((event) => createDashboardItem(`${event.title} · ${formatEventDateRange(event)}`, '/calendar')),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'club-status',
|
||||
title: 'Vereinsstatus',
|
||||
cards: [
|
||||
{
|
||||
title: 'Mitglieder',
|
||||
value: `${members.length} aktiv`,
|
||||
meta: members.length > 0 ? `${members.filter((member) => {
|
||||
const createdAt = new Date(member.createdAt);
|
||||
return createdAt.getFullYear() === today.getFullYear();
|
||||
}).length} dieses Jahr angelegt` : null,
|
||||
to: '/members',
|
||||
items: recentMembers.map((member) => {
|
||||
const name = [member.firstName, member.lastName].filter(Boolean).join(' ').trim() || member.email || `Mitglied ${member.id}`;
|
||||
return createDashboardItem(name, buildMemberRoute(member.id, 'active'));
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'Anfragen',
|
||||
value: `${requests.length}`,
|
||||
meta: `${openRequestCount} offen, ${inProgressRequestCount} in Bearbeitung`,
|
||||
to: '/club-requests',
|
||||
},
|
||||
{
|
||||
title: 'Workflow-Fortschritt',
|
||||
value: `${workflowStageCounts.onboarding_completed || 0}`,
|
||||
meta: 'Onboardings abgeschlossen',
|
||||
to: '/club-requests',
|
||||
items: [
|
||||
createDashboardItem(`${workflowStageCounts.admission_prepared || 0} Aufnahmen vorbereitet`, '/club-requests'),
|
||||
createDashboardItem(`${workflowStageCounts.member_record_created || 0} Mitglieder angelegt`, '/club-requests'),
|
||||
createDashboardItem(`${workflowStageCounts.sepa_pending || 0} warten auf SEPA`, '/club-requests'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Finanzen',
|
||||
value: paidRatio === null ? 'Keine Daten' : `${paidRatio} % erledigt`,
|
||||
meta: paymentClaims.length > 0 ? `${paymentClaims.length} offene oder teilweise offene Forderungen` : 'Noch keine Beitragsforderungen erfasst',
|
||||
to: '/club-tasks',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'recent-activity',
|
||||
title: 'Letzte Aktivitäten',
|
||||
cards: [
|
||||
{
|
||||
title: 'Zuletzt eingegangen',
|
||||
to: '/club-requests',
|
||||
items: requests.slice(0, 4).map((request) => {
|
||||
const name = [request.firstName, request.lastName].filter(Boolean).join(' ').trim() || request.email || 'Unbekannt';
|
||||
const workflow = request.workflowStage ? ` · ${formatRequestWorkflowStage(request.workflowStage)}` : '';
|
||||
return createDashboardItem(
|
||||
`${name} · ${request.subject || request.requestType}${workflow}`,
|
||||
buildRequestRoute(request.id, request.status || '')
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'Aktuelle Aufgaben',
|
||||
to: '/club-tasks',
|
||||
items: visibleDashboardTasks.slice(0, 4).map((task) => taskDashboardItem(task, `${formatTaskType(task.taskType)} · ${task.status}`)),
|
||||
},
|
||||
{
|
||||
title: 'Automatik zuletzt aktiv',
|
||||
to: '/club-tasks',
|
||||
items: automatedTasks.slice(0, 4).map((task) => {
|
||||
const sourceLabel = task.automationSource === 'club_requests'
|
||||
? 'Anfrage'
|
||||
: task.automationSource === 'club_payment_claims'
|
||||
? 'Zahlung'
|
||||
: task.automationSource === 'club_documents'
|
||||
? 'Dokument'
|
||||
: task.automationSource === 'club_invoice_parties'
|
||||
? 'Sponsor'
|
||||
: task.automationSource === 'club_invoices'
|
||||
? 'Rechnung'
|
||||
: task.automationSource === 'club_communication'
|
||||
? 'Kommunikation'
|
||||
: task.automationSource === 'calendar_events'
|
||||
? 'Termin'
|
||||
: 'Workflow';
|
||||
return taskDashboardItem(task, `${formatTaskType(task.taskType)} · ${sourceLabel}`);
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
res.status(200).json({ sections });
|
||||
} catch (error) {
|
||||
console.error('[getClubDashboard] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Dashboard konnte nicht geladen werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
111
backend/controllers/clubDocumentController.js
Normal file
111
backend/controllers/clubDocumentController.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import clubDocumentService from '../services/clubDocumentService.js';
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
fs.mkdirSync('uploads/temp', { recursive: true });
|
||||
cb(null, 'uploads/temp/');
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
cb(null, `${file.fieldname}-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 20 * 1024 * 1024,
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedExtensions = ['.pdf', '.doc', '.docx', '.txt', '.csv', '.odt', '.xlsx'];
|
||||
const allowedMimePatterns = ['pdf', 'msword', 'wordprocessingml.document', 'text/plain', 'csv', 'excel', 'opendocument.text'];
|
||||
const extensionValid = allowedExtensions.includes(path.extname(file.originalname).toLowerCase());
|
||||
const mimetypeValid = allowedMimePatterns.some((pattern) => file.mimetype && file.mimetype.toLowerCase().includes(pattern));
|
||||
if (extensionValid && mimetypeValid) {
|
||||
return cb(null, true);
|
||||
}
|
||||
cb(new Error('Nur PDF, DOC, DOCX, TXT, CSV, ODT und XLSX Dateien sind erlaubt!'));
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadMiddleware = upload.single('document');
|
||||
|
||||
function cleanupTempFile(file) {
|
||||
if (file?.path && fs.existsSync(file.path)) {
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
export const listClubDocuments = async (req, res) => {
|
||||
try {
|
||||
const documents = await clubDocumentService.listClubDocuments(Number(req.params.clubId), req.query || {});
|
||||
res.status(200).json({ documents });
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubDocument = async (req, res) => {
|
||||
try {
|
||||
const document = await clubDocumentService.createClubDocument(Number(req.params.clubId), req.user?.id || null, req.body || {}, req.file || null);
|
||||
res.status(201).json({ document });
|
||||
} catch (error) {
|
||||
cleanupTempFile(req.file);
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubDocument = async (req, res) => {
|
||||
try {
|
||||
const document = await clubDocumentService.updateClubDocument(Number(req.params.clubId), Number(req.params.documentId), req.user?.id || null, req.body || {}, req.file || null);
|
||||
res.status(200).json({ document });
|
||||
} catch (error) {
|
||||
cleanupTempFile(req.file);
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const archiveClubDocument = async (req, res) => {
|
||||
try {
|
||||
const document = await clubDocumentService.archiveClubDocument(Number(req.params.clubId), Number(req.params.documentId));
|
||||
res.status(200).json({ document });
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubDocument = async (req, res) => {
|
||||
try {
|
||||
await clubDocumentService.deleteClubDocument(Number(req.params.clubId), Number(req.params.documentId));
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadClubDocument = async (req, res) => {
|
||||
try {
|
||||
const versionNo = req.query.versionNo ? Number(req.query.versionNo) : null;
|
||||
const { document, version } = await clubDocumentService.getDocumentDownload(Number(req.params.clubId), Number(req.params.documentId), versionNo);
|
||||
if (!fs.existsSync(version.storagePath)) {
|
||||
return res.status(404).json({ error: 'filenotfound' });
|
||||
}
|
||||
res.setHeader('Content-Disposition', `inline; filename="${document.title || version.fileName}"`);
|
||||
res.setHeader('Content-Type', version.mimeType || 'application/octet-stream');
|
||||
res.sendFile(version.storagePath);
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
listClubDocuments,
|
||||
createClubDocument,
|
||||
updateClubDocument,
|
||||
archiveClubDocument,
|
||||
deleteClubDocument,
|
||||
downloadClubDocument,
|
||||
};
|
||||
93
backend/controllers/clubInvoiceController.js
Normal file
93
backend/controllers/clubInvoiceController.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import clubInvoiceService from '../services/clubInvoiceService.js';
|
||||
|
||||
class ClubInvoiceController {
|
||||
async listClubInvoices(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = await clubInvoiceService.listClubInvoices(Number(clubId));
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
console.error('[listClubInvoices] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungen konnten nicht geladen werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async createInvoiceParty(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const party = await clubInvoiceService.createInvoiceParty(Number(clubId), req.body || {});
|
||||
res.status(201).json({ party });
|
||||
} catch (error) {
|
||||
console.error('[createInvoiceParty] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateInvoiceParty(req, res) {
|
||||
try {
|
||||
const { clubId, partyId } = req.params;
|
||||
const party = await clubInvoiceService.updateInvoiceParty(Number(clubId), Number(partyId), req.body || {});
|
||||
res.json({ party });
|
||||
} catch (error) {
|
||||
console.error('[updateInvoiceParty] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteInvoiceParty(req, res) {
|
||||
try {
|
||||
const { clubId, partyId } = req.params;
|
||||
await clubInvoiceService.deleteInvoiceParty(Number(clubId), Number(partyId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteInvoiceParty] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async createInvoice(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const invoice = await clubInvoiceService.createInvoice(Number(clubId), req.user?.id || null, req.body || {});
|
||||
res.status(201).json({ invoice });
|
||||
} catch (error) {
|
||||
console.error('[createInvoice] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateInvoice(req, res) {
|
||||
try {
|
||||
const { clubId, invoiceId } = req.params;
|
||||
const invoice = await clubInvoiceService.updateInvoice(Number(clubId), Number(invoiceId), req.body || {});
|
||||
res.json({ invoice });
|
||||
} catch (error) {
|
||||
console.error('[updateInvoice] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateInvoiceStatus(req, res) {
|
||||
try {
|
||||
const { clubId, invoiceId } = req.params;
|
||||
const invoice = await clubInvoiceService.updateInvoiceStatus(Number(clubId), Number(invoiceId), String(req.body?.status || ''));
|
||||
res.json({ invoice });
|
||||
} catch (error) {
|
||||
console.error('[updateInvoiceStatus] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungsstatus konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteInvoice(req, res) {
|
||||
try {
|
||||
const { clubId, invoiceId } = req.params;
|
||||
await clubInvoiceService.deleteInvoice(Number(clubId), Number(invoiceId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteInvoice] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubInvoiceController();
|
||||
73
backend/controllers/clubPaymentClaimController.js
Normal file
73
backend/controllers/clubPaymentClaimController.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import clubPaymentClaimService from '../services/clubPaymentClaimService.js';
|
||||
|
||||
class ClubPaymentClaimController {
|
||||
async list(req, res) {
|
||||
try {
|
||||
const payload = await clubPaymentClaimService.listClaims(Number(req.params.clubId));
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
console.error('[listClubPaymentClaims] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderungen konnten nicht geladen werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.createClaim(Number(req.params.clubId), req.body || {});
|
||||
res.status(201).json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[createClubPaymentClaim] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.updateClaim(Number(req.params.clubId), Number(req.params.claimId), req.body || {});
|
||||
res.json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[updateClubPaymentClaim] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.updateClaimStatus(
|
||||
Number(req.params.clubId),
|
||||
Number(req.params.claimId),
|
||||
String(req.body?.status || '')
|
||||
);
|
||||
res.json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[updateClubPaymentClaimStatus] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Status konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async registerPayment(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.registerPayment(
|
||||
Number(req.params.clubId),
|
||||
Number(req.params.claimId),
|
||||
req.body || {}
|
||||
);
|
||||
res.json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[registerClubPaymentClaimPayment] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Teilzahlung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req, res) {
|
||||
try {
|
||||
await clubPaymentClaimService.deleteClaim(Number(req.params.clubId), Number(req.params.claimId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubPaymentClaim] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubPaymentClaimController();
|
||||
174
backend/controllers/clubRequestController.js
Normal file
174
backend/controllers/clubRequestController.js
Normal file
@@ -0,0 +1,174 @@
|
||||
import { ClubRequest, ClubRequestNote } from '../models/index.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorUtils.js';
|
||||
|
||||
const TERMINAL_REQUEST_STATUSES = new Set(['converted', 'rejected', 'archived']);
|
||||
|
||||
function isMissingRequestTableError(error) {
|
||||
return error?.original?.code === 'ER_NO_SUCH_TABLE'
|
||||
&& /club_requests|club_request_notes/.test(String(error?.original?.sqlMessage || ''));
|
||||
}
|
||||
|
||||
function normalizeRequestPayload(payload = {}) {
|
||||
return {
|
||||
requestType: payload.requestType || 'contact',
|
||||
subject: payload.subject?.trim() || null,
|
||||
firstName: payload.firstName?.trim() || null,
|
||||
lastName: payload.lastName?.trim() || null,
|
||||
email: payload.email?.trim() || null,
|
||||
phone: payload.phone?.trim() || null,
|
||||
message: payload.message?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRequestOrThrow(clubId, requestId) {
|
||||
const request = await ClubRequest.findOne({
|
||||
where: {
|
||||
id: requestId,
|
||||
clubId,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: ClubRequestNote,
|
||||
as: 'notes',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [[{ model: ClubRequestNote, as: 'notes' }, 'createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
const error = new Error('Anfrage wurde nicht gefunden.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
export const listClubRequests = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
let requests = [];
|
||||
try {
|
||||
requests = await ClubRequest.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{
|
||||
model: ClubRequestNote,
|
||||
as: 'notes',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['receivedAt', 'DESC'],
|
||||
[{ model: ClubRequestNote, as: 'notes' }, 'createdAt', 'DESC'],
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isMissingRequestTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ requests });
|
||||
} catch (error) {
|
||||
console.error('[listClubRequests] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Anfragen konnten nicht geladen werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubRequest = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = normalizeRequestPayload(req.body);
|
||||
|
||||
if (!payload.subject && !payload.message) {
|
||||
return res.status(400).json({ error: 'Betreff oder Nachricht sind erforderlich.' });
|
||||
}
|
||||
|
||||
const request = await ClubRequest.create({
|
||||
clubId,
|
||||
...payload,
|
||||
receivedAt: new Date(),
|
||||
});
|
||||
|
||||
const created = await loadRequestOrThrow(clubId, request.id);
|
||||
res.status(201).json({ request: created });
|
||||
} catch (error) {
|
||||
console.error('[createClubRequest] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Anfrage konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubRequest = async (req, res) => {
|
||||
try {
|
||||
const { clubId, requestId } = req.params;
|
||||
const request = await loadRequestOrThrow(clubId, requestId);
|
||||
const payload = normalizeRequestPayload(req.body);
|
||||
|
||||
await request.update(payload);
|
||||
const updated = await loadRequestOrThrow(clubId, requestId);
|
||||
res.status(200).json({ request: updated });
|
||||
} catch (error) {
|
||||
console.error('[updateClubRequest] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Anfrage konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubRequestStatus = async (req, res) => {
|
||||
try {
|
||||
const { clubId, requestId } = req.params;
|
||||
const { status } = req.body || {};
|
||||
|
||||
if (!status) {
|
||||
return res.status(400).json({ error: 'Status fehlt.' });
|
||||
}
|
||||
|
||||
const request = await loadRequestOrThrow(clubId, requestId);
|
||||
await request.update({
|
||||
status,
|
||||
closedAt: TERMINAL_REQUEST_STATUSES.has(status) ? new Date() : null,
|
||||
});
|
||||
|
||||
const updated = await loadRequestOrThrow(clubId, requestId);
|
||||
res.status(200).json({ request: updated });
|
||||
} catch (error) {
|
||||
console.error('[updateClubRequestStatus] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Status konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const addClubRequestNote = async (req, res) => {
|
||||
try {
|
||||
const { clubId, requestId } = req.params;
|
||||
const body = String(req.body?.body || '').trim();
|
||||
|
||||
if (!body) {
|
||||
return res.status(400).json({ error: 'Notiztext fehlt.' });
|
||||
}
|
||||
|
||||
await loadRequestOrThrow(clubId, requestId);
|
||||
|
||||
await ClubRequestNote.create({
|
||||
clubRequestId: requestId,
|
||||
createdByUserId: req.user?.id || null,
|
||||
body,
|
||||
});
|
||||
|
||||
const updated = await loadRequestOrThrow(clubId, requestId);
|
||||
res.status(201).json({ request: updated });
|
||||
} catch (error) {
|
||||
console.error('[addClubRequestNote] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Notiz konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
19
backend/controllers/clubStatisticsController.js
Normal file
19
backend/controllers/clubStatisticsController.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import clubStatisticsService from '../services/clubStatisticsService.js';
|
||||
|
||||
class ClubStatisticsController {
|
||||
async getClubStatistics(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const statistics = await clubStatisticsService.getClubStatistics(clubId);
|
||||
res.json(statistics);
|
||||
} catch (error) {
|
||||
console.error('[getClubStatistics] - Error:', error);
|
||||
if (error?.status) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Fehler beim Laden der Vereinsstatistiken' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubStatisticsController();
|
||||
275
backend/controllers/clubTaskController.js
Normal file
275
backend/controllers/clubTaskController.js
Normal file
@@ -0,0 +1,275 @@
|
||||
import { ClubTask, User, UserClub } from '../models/index.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorUtils.js';
|
||||
import clubTaskAutomationService from '../services/clubTaskAutomationService.js';
|
||||
import clubWorkflowSourceService from '../services/clubWorkflowSourceService.js';
|
||||
|
||||
const TERMINAL_TASK_STATUSES = new Set(['done', 'cancelled', 'archived']);
|
||||
|
||||
function isMissingTaskTableError(error) {
|
||||
return error?.original?.code === 'ER_NO_SUCH_TABLE'
|
||||
&& /club_tasks/.test(String(error?.original?.sqlMessage || ''));
|
||||
}
|
||||
|
||||
function isMissingTaskSuppressionTableError(error) {
|
||||
return error?.original?.code === 'ER_NO_SUCH_TABLE'
|
||||
&& /club_task_suppressions/.test(String(error?.original?.sqlMessage || ''));
|
||||
}
|
||||
|
||||
function normalizeTaskPayload(payload = {}) {
|
||||
return {
|
||||
title: String(payload.title || '').trim(),
|
||||
taskType: payload.taskType?.trim() || null,
|
||||
description: payload.description?.trim() || null,
|
||||
status: payload.status || 'open',
|
||||
priority: payload.priority || 'normal',
|
||||
dueAt: payload.dueAt || null,
|
||||
remindAt: payload.remindAt || null,
|
||||
assignedUserId: payload.assignedUserId ? Number(payload.assignedUserId) : null,
|
||||
automationSource: payload.automationSource?.trim() || null,
|
||||
automationKey: payload.automationKey?.trim() || null,
|
||||
relatedEntityType: payload.relatedEntityType?.trim() || null,
|
||||
relatedEntityId: payload.relatedEntityId ? Number(payload.relatedEntityId) : null,
|
||||
sourceSnapshot: payload.sourceSnapshot || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAssignableUsers(clubId) {
|
||||
const entries = await UserClub.findAll({
|
||||
where: { clubId },
|
||||
include: [{ model: User, as: 'user', attributes: ['id', 'email'] }],
|
||||
order: [[{ model: User, as: 'user' }, 'email', 'ASC']],
|
||||
});
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.user)
|
||||
.filter((entry) => entry.approved || entry.isOwner)
|
||||
.map((entry) => ({
|
||||
userId: entry.userId,
|
||||
email: entry.user.email,
|
||||
isOwner: Boolean(entry.isOwner),
|
||||
approved: Boolean(entry.approved),
|
||||
}));
|
||||
}
|
||||
|
||||
async function validateAssignedUser(clubId, assignedUserId) {
|
||||
if (!assignedUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userClub = await UserClub.findOne({
|
||||
where: {
|
||||
clubId,
|
||||
userId: assignedUserId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userClub || (!userClub.approved && !userClub.isOwner)) {
|
||||
const error = new Error('Der zugewiesene Benutzer gehört nicht zu diesem Verein.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return assignedUserId;
|
||||
}
|
||||
|
||||
async function loadTaskOrThrow(clubId, taskId) {
|
||||
const task = await ClubTask.findOne({
|
||||
where: {
|
||||
id: taskId,
|
||||
clubId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
const error = new Error('Aufgabe wurde nicht gefunden.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export const listClubTasks = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
let tasks = [];
|
||||
let automationOverview = { definitions: [], suggestions: [] };
|
||||
|
||||
try {
|
||||
[tasks, automationOverview] = await Promise.all([
|
||||
ClubTask.findAll({
|
||||
where: { clubId },
|
||||
include: [{ model: User, as: 'assignedUser', attributes: ['id', 'email'], required: false }],
|
||||
order: [
|
||||
['status', 'ASC'],
|
||||
['dueAt', 'ASC'],
|
||||
['updatedAt', 'DESC'],
|
||||
],
|
||||
}),
|
||||
clubTaskAutomationService.buildAutomationOverview(clubId),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!isMissingTaskTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const assignableUsers = await loadAssignableUsers(clubId);
|
||||
|
||||
res.status(200).json({
|
||||
tasks,
|
||||
taskDefinitions: automationOverview.definitions,
|
||||
workflowSources: automationOverview.workflowSources || [],
|
||||
taskSuggestions: automationOverview.suggestions,
|
||||
assignableUsers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[listClubTasks] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Aufgaben konnten nicht geladen werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubTask = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = normalizeTaskPayload(req.body);
|
||||
|
||||
if (!payload.title) {
|
||||
return res.status(400).json({ error: 'Titel ist erforderlich.' });
|
||||
}
|
||||
|
||||
payload.assignedUserId = await validateAssignedUser(clubId, payload.assignedUserId);
|
||||
|
||||
const task = await ClubTask.create({
|
||||
clubId,
|
||||
...payload,
|
||||
createdByUserId: req.user?.id || null,
|
||||
});
|
||||
|
||||
res.status(201).json({ task });
|
||||
} catch (error) {
|
||||
console.error('[createClubTask] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubTask = async (req, res) => {
|
||||
try {
|
||||
const { clubId, taskId } = req.params;
|
||||
const task = await loadTaskOrThrow(clubId, taskId);
|
||||
const payload = normalizeTaskPayload(req.body);
|
||||
const wasDoneBefore = task.status === 'done';
|
||||
|
||||
if (!payload.title) {
|
||||
return res.status(400).json({ error: 'Titel ist erforderlich.' });
|
||||
}
|
||||
|
||||
payload.assignedUserId = await validateAssignedUser(clubId, payload.assignedUserId);
|
||||
|
||||
await task.update({
|
||||
...payload,
|
||||
completedAt: TERMINAL_TASK_STATUSES.has(payload.status) ? (task.completedAt || new Date()) : null,
|
||||
archivedAt: payload.status === 'archived' ? (task.archivedAt || new Date()) : null,
|
||||
});
|
||||
|
||||
const followUpTasks = !wasDoneBefore && payload.status === 'done'
|
||||
? await clubTaskAutomationService.materializeWorkflowFollowUps(task, req.user?.id || null)
|
||||
: [];
|
||||
const sourceUpdate = !wasDoneBefore && payload.status === 'done'
|
||||
? await clubWorkflowSourceService.syncSourceStateForCompletedTask(task)
|
||||
: null;
|
||||
|
||||
res.status(200).json({ task, followUpTasks, sourceUpdate });
|
||||
} catch (error) {
|
||||
console.error('[updateClubTask] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubTaskStatus = async (req, res) => {
|
||||
try {
|
||||
const { clubId, taskId } = req.params;
|
||||
const { status } = req.body || {};
|
||||
if (!status) {
|
||||
return res.status(400).json({ error: 'Status fehlt.' });
|
||||
}
|
||||
|
||||
const task = await loadTaskOrThrow(clubId, taskId);
|
||||
const wasDoneBefore = task.status === 'done';
|
||||
await task.update({
|
||||
status,
|
||||
completedAt: TERMINAL_TASK_STATUSES.has(status) ? (task.completedAt || new Date()) : null,
|
||||
archivedAt: status === 'archived' ? (task.archivedAt || new Date()) : null,
|
||||
});
|
||||
const followUpTasks = !wasDoneBefore && status === 'done'
|
||||
? await clubTaskAutomationService.materializeWorkflowFollowUps(task, req.user?.id || null)
|
||||
: [];
|
||||
const sourceUpdate = !wasDoneBefore && status === 'done'
|
||||
? await clubWorkflowSourceService.syncSourceStateForCompletedTask(task)
|
||||
: null;
|
||||
res.status(200).json({ task, followUpTasks, sourceUpdate });
|
||||
} catch (error) {
|
||||
console.error('[updateClubTaskStatus] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Status konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const materializeAutomatedClubTasks = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const automationKeys = Array.isArray(req.body?.automationKeys) ? req.body.automationKeys : [];
|
||||
|
||||
if (automationKeys.length === 0) {
|
||||
return res.status(400).json({ error: 'Es wurden keine Automatik-Schlüssel übergeben.' });
|
||||
}
|
||||
|
||||
const tasks = await clubTaskAutomationService.materializeSuggestions(clubId, req.user?.id || null, automationKeys);
|
||||
res.status(201).json({ tasks });
|
||||
} catch (error) {
|
||||
console.error('[materializeAutomatedClubTasks] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Automatische Aufgaben konnten nicht erstellt werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const dismissAutomatedClubTaskSuggestion = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = req.body || {};
|
||||
const suppression = await clubTaskAutomationService.dismissSuggestion(clubId, req.user?.id || null, payload);
|
||||
res.status(200).json({ success: true, suppression });
|
||||
} catch (error) {
|
||||
console.error('[dismissAutomatedClubTaskSuggestion] - Error:', error);
|
||||
if (isMissingTaskSuppressionTableError(error)) {
|
||||
return res.status(500).json({
|
||||
error: 'Die Tabelle club_task_suppressions fehlt noch. Bitte die aktuelle SQL-Datei auf dem System ausführen.',
|
||||
});
|
||||
}
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Vorschlag konnte nicht ausgeblendet werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubTask = async (req, res) => {
|
||||
try {
|
||||
const { clubId, taskId } = req.params;
|
||||
const task = await loadTaskOrThrow(clubId, taskId);
|
||||
await task.destroy();
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubTask] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gelöscht werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -67,7 +67,12 @@ export const updateClubSettings = async (req, res) => {
|
||||
autoFetchRankings,
|
||||
countryCode,
|
||||
stateCode,
|
||||
memberDataQualityRequirements
|
||||
memberDataQualityRequirements,
|
||||
feeRules,
|
||||
outgoingInvoicePrefix,
|
||||
outgoingInvoiceNextNumber,
|
||||
incomingInvoicePrefix,
|
||||
incomingInvoiceNextNumber
|
||||
} = req.body;
|
||||
const updated = await ClubService.updateClubSettings(token, clubid, {
|
||||
greetingText,
|
||||
@@ -76,7 +81,12 @@ export const updateClubSettings = async (req, res) => {
|
||||
autoFetchRankings,
|
||||
countryCode,
|
||||
stateCode,
|
||||
memberDataQualityRequirements
|
||||
memberDataQualityRequirements,
|
||||
feeRules,
|
||||
outgoingInvoicePrefix,
|
||||
outgoingInvoiceNextNumber,
|
||||
incomingInvoicePrefix,
|
||||
incomingInvoiceNextNumber
|
||||
});
|
||||
res.status(200).json(updated);
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,12 +28,13 @@ const getWaitingApprovals = async(req, res) => {
|
||||
|
||||
const setClubMembers = async (req, res) => {
|
||||
try {
|
||||
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
|
||||
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts } = req.body;
|
||||
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
|
||||
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved,
|
||||
contributionGroupCode, contacts } = req.body;
|
||||
const { id: clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate,
|
||||
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts);
|
||||
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contributionGroupCode, contacts);
|
||||
|
||||
// Emit Socket-Event wenn Member erfolgreich erstellt/aktualisiert wurde
|
||||
if (addResult.status === 200) {
|
||||
@@ -47,6 +48,33 @@ const setClubMembers = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const getMemberSepaMandate = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.getMemberSepaMandate(userToken, Number(clubId), Number(memberId));
|
||||
res.status(result.status || 500).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[getMemberSepaMandate] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'SEPA-Mandat konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const saveMemberSepaMandate = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.saveMemberSepaMandate(userToken, Number(clubId), Number(memberId), req.body || {});
|
||||
if (result.status === 200) {
|
||||
emitMemberChanged(clubId);
|
||||
}
|
||||
res.status(result.status || 500).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[saveMemberSepaMandate] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'SEPA-Mandat konnte nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const getMemberPlayInterests = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
@@ -327,6 +355,8 @@ export {
|
||||
getClubMembers,
|
||||
getWaitingApprovals,
|
||||
setClubMembers,
|
||||
getMemberSepaMandate,
|
||||
saveMemberSepaMandate,
|
||||
getMemberPlayInterests,
|
||||
setMemberPlayInterest,
|
||||
uploadMemberImage,
|
||||
|
||||
@@ -10,6 +10,7 @@ import Season from '../models/Season.js';
|
||||
import User from '../models/User.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
import { hasUserClubAccess } from '../utils/userUtils.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const teamDataFetchJobs = new Map();
|
||||
@@ -635,15 +636,34 @@ class MyTischtennisUrlController {
|
||||
/**
|
||||
* Configure league from myTischtennis table URL
|
||||
* POST /api/mytischtennis/configure-league
|
||||
* Body: { url: string, createSeason?: boolean }
|
||||
* Body: { url: string, clubId: number, createSeason?: boolean }
|
||||
*/
|
||||
async configureLeague(req, res, next) {
|
||||
try {
|
||||
const { url, createSeason } = req.body;
|
||||
const { url, createSeason, clubId } = req.body;
|
||||
const userIdOrEmail = req.headers.userid;
|
||||
|
||||
if (!url) {
|
||||
throw new HttpError('URL is required', 400);
|
||||
if (!url || !clubId) {
|
||||
throw new HttpError('URL and clubId are required', 400);
|
||||
}
|
||||
|
||||
let userId = userIdOrEmail;
|
||||
if (isNaN(userIdOrEmail)) {
|
||||
const user = await User.findOne({ where: { email: userIdOrEmail } });
|
||||
if (!user) {
|
||||
throw new HttpError('User not found', 404);
|
||||
}
|
||||
userId = user.id;
|
||||
}
|
||||
|
||||
const normalizedClubId = Number.parseInt(clubId, 10);
|
||||
if (!Number.isInteger(normalizedClubId) || normalizedClubId <= 0) {
|
||||
throw new HttpError('clubId must be a valid number', 400);
|
||||
}
|
||||
|
||||
const hasAccess = await hasUserClubAccess(userId, normalizedClubId);
|
||||
if (!hasAccess) {
|
||||
throw new HttpError('Keine Berechtigung für diesen Verein', 403);
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
@@ -669,6 +689,7 @@ class MyTischtennisUrlController {
|
||||
// Find or create league
|
||||
let league = await League.findOne({
|
||||
where: {
|
||||
clubId: normalizedClubId,
|
||||
myTischtennisGroupId: parsedData.groupId,
|
||||
association: parsedData.association
|
||||
}
|
||||
@@ -677,6 +698,7 @@ class MyTischtennisUrlController {
|
||||
if (!league) {
|
||||
league = await League.create({
|
||||
name: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version
|
||||
clubId: normalizedClubId,
|
||||
myTischtennisGroupId: parsedData.groupId,
|
||||
association: parsedData.association,
|
||||
groupname: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version
|
||||
|
||||
@@ -76,6 +76,29 @@ export const updateUserRole = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUserRoles = async (req, res) => {
|
||||
try {
|
||||
const { clubId, userId: targetUserId } = req.params;
|
||||
const { roleIds } = req.body;
|
||||
const updatingUserId = req.user.id;
|
||||
|
||||
const result = await permissionService.setUserRoles(
|
||||
parseInt(targetUserId),
|
||||
parseInt(clubId),
|
||||
Array.isArray(roleIds) ? roleIds : [],
|
||||
updatingUserId
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error updating user roles:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user custom permissions
|
||||
*/
|
||||
@@ -128,6 +151,62 @@ export const getPermissionStructure = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getClubRoles = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const roles = await permissionService.getClubRoles(parseInt(clubId, 10), req.user.id);
|
||||
res.json(roles);
|
||||
} catch (error) {
|
||||
console.error('Error getting club roles:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubRole = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const role = await permissionService.createClubRole(parseInt(clubId, 10), req.body || {}, req.user.id);
|
||||
res.status(201).json(role);
|
||||
} catch (error) {
|
||||
console.error('Error creating club role:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubRole = async (req, res) => {
|
||||
try {
|
||||
const { clubId, roleId } = req.params;
|
||||
const role = await permissionService.updateClubRole(parseInt(clubId, 10), parseInt(roleId, 10), req.body || {}, req.user.id);
|
||||
res.json(role);
|
||||
} catch (error) {
|
||||
console.error('Error updating club role:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubRole = async (req, res) => {
|
||||
try {
|
||||
const { clubId, roleId } = req.params;
|
||||
const result = await permissionService.deleteClubRole(parseInt(clubId, 10), parseInt(roleId, 10), req.user.id);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error deleting club role:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user status (activate/deactivate)
|
||||
*/
|
||||
@@ -158,10 +237,14 @@ export default {
|
||||
getUserPermissions,
|
||||
getClubMembersWithPermissions,
|
||||
updateUserRole,
|
||||
updateUserRoles,
|
||||
updateUserPermissions,
|
||||
updateUserStatus,
|
||||
getAvailableRoles,
|
||||
getPermissionStructure
|
||||
getPermissionStructure,
|
||||
getClubRoles,
|
||||
createClubRole,
|
||||
updateClubRole,
|
||||
deleteClubRole,
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ export const requireAdmin = () => {
|
||||
parseInt(clubId)
|
||||
);
|
||||
|
||||
if (!userPermissions || (userPermissions.role !== 'admin' && !userPermissions.isOwner)) {
|
||||
if (!userPermissions || (!userPermissions.isAdmin && !userPermissions.isOwner)) {
|
||||
return res.status(403).json({
|
||||
error: 'Keine Berechtigung',
|
||||
details: 'Administrator-Rechte erforderlich'
|
||||
@@ -190,7 +190,10 @@ export const requireRole = (roles) => {
|
||||
parseInt(clubId)
|
||||
);
|
||||
|
||||
if (!userPermissions || !roles.includes(userPermissions.role)) {
|
||||
const assignedRoleKeys = Array.isArray(userPermissions?.roles)
|
||||
? userPermissions.roles.map((role) => role.roleKey)
|
||||
: [];
|
||||
if (!userPermissions || (!roles.includes(userPermissions.role) && !assignedRoleKeys.some((roleKey) => roles.includes(roleKey)))) {
|
||||
return res.status(403).json({
|
||||
error: 'Keine Berechtigung',
|
||||
details: `Erforderliche Rolle: ${roles.join(', ')}`
|
||||
@@ -212,4 +215,3 @@ export default {
|
||||
requireAdmin,
|
||||
requireRole
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- club_payment_claims: Feld wie backend/models/ClubPaymentClaim.js (paidAmountCents)
|
||||
-- Fehlt in der DB -> SequelizeDatabaseError ER_BAD_FIELD_ERROR in Club-Dashboard,
|
||||
-- Aufgaben-Automation, Konten und Zahlungsforderungen.
|
||||
--
|
||||
-- Diese Migration ist idempotent:
|
||||
-- - fuegt `paid_amount_cents` nur hinzu, wenn die Spalte noch fehlt
|
||||
-- - initialisiert bestehende Datensaetze mit Status `paid` auf `amount_cents`
|
||||
-- Hinweis:
|
||||
-- Bereits teilweise bezahlte Altfaelle koennen ohne historische Buchungsdaten
|
||||
-- nicht exakt rekonstruiert werden und bleiben daher initial bei 0.
|
||||
|
||||
SET @column_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'club_payment_claims'
|
||||
AND COLUMN_NAME = 'paid_amount_cents'
|
||||
);
|
||||
|
||||
SET @add_column_sql := IF(
|
||||
@column_exists = 0,
|
||||
'ALTER TABLE `club_payment_claims`
|
||||
ADD COLUMN `paid_amount_cents` BIGINT NOT NULL DEFAULT 0
|
||||
COMMENT ''Bereits bezahlter Anteil in Cent''
|
||||
AFTER `amount_cents`',
|
||||
'SELECT ''Column paid_amount_cents already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_column_stmt FROM @add_column_sql;
|
||||
EXECUTE add_column_stmt;
|
||||
DEALLOCATE PREPARE add_column_stmt;
|
||||
|
||||
UPDATE `club_payment_claims`
|
||||
SET `paid_amount_cents` = `amount_cents`
|
||||
WHERE `status` = 'paid'
|
||||
AND COALESCE(`paid_amount_cents`, 0) = 0;
|
||||
@@ -1,14 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
club_id INT NOT NULL,
|
||||
event_type VARCHAR(32) NOT NULL DEFAULT 'club_event',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'planning',
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
location VARCHAR(255) NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
registration_deadline DATE NULL,
|
||||
organizer_user_id INT NULL,
|
||||
category VARCHAR(64) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_calendar_events_club_start (club_id, start_date),
|
||||
KEY idx_calendar_events_club_status (club_id, status),
|
||||
KEY idx_calendar_events_club_event_type (club_id, event_type),
|
||||
CONSTRAINT fk_calendar_events_club
|
||||
FOREIGN KEY (club_id) REFERENCES clubs(id)
|
||||
ON DELETE CASCADE
|
||||
|
||||
@@ -10,16 +10,44 @@ const CalendarEvent = sequelize.define('CalendarEvent', {
|
||||
references: { model: Club, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
eventType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'club_event',
|
||||
field: 'event_type',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'planning',
|
||||
},
|
||||
title: { type: DataTypes.STRING(255), allowNull: false },
|
||||
description: { type: DataTypes.TEXT, allowNull: true },
|
||||
location: { type: DataTypes.STRING(255), allowNull: true },
|
||||
startDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'start_date' },
|
||||
endDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'end_date' },
|
||||
registrationDeadline: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'registration_deadline',
|
||||
},
|
||||
organizerUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'organizer_user_id',
|
||||
},
|
||||
category: { type: DataTypes.STRING(64), allowNull: true },
|
||||
notes: { type: DataTypes.TEXT, allowNull: true },
|
||||
}, {
|
||||
tableName: 'calendar_events',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [{ fields: ['club_id', 'start_date'] }],
|
||||
indexes: [
|
||||
{ fields: ['club_id', 'start_date'] },
|
||||
{ fields: ['club_id', 'event_type'] },
|
||||
{ fields: ['club_id', 'status'] },
|
||||
{ fields: ['club_id', 'registration_deadline'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default CalendarEvent;
|
||||
|
||||
@@ -48,6 +48,36 @@ const Club = sequelize.define('Club', {
|
||||
allowNull: true,
|
||||
field: 'member_data_quality_requirements',
|
||||
comment: 'Configures which member fields are required for data quality checks'
|
||||
},
|
||||
feeRules: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'fee_rules',
|
||||
comment: 'Manual contribution rules for common club member groups'
|
||||
},
|
||||
outgoingInvoicePrefix: {
|
||||
type: DataTypes.STRING(24),
|
||||
allowNull: false,
|
||||
defaultValue: 'RE',
|
||||
field: 'outgoing_invoice_prefix'
|
||||
},
|
||||
outgoingInvoiceNextNumber: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'outgoing_invoice_next_number'
|
||||
},
|
||||
incomingInvoicePrefix: {
|
||||
type: DataTypes.STRING(24),
|
||||
allowNull: false,
|
||||
defaultValue: 'EI',
|
||||
field: 'incoming_invoice_prefix'
|
||||
},
|
||||
incomingInvoiceNextNumber: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'incoming_invoice_next_number'
|
||||
}
|
||||
}, {
|
||||
tableName: 'clubs',
|
||||
|
||||
94
backend/models/ClubAccount.js
Normal file
94
backend/models/ClubAccount.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubAccount = sequelize.define('ClubAccount', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(160),
|
||||
allowNull: false,
|
||||
},
|
||||
accountHolder: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'account_holder',
|
||||
},
|
||||
bankName: {
|
||||
type: DataTypes.STRING(160),
|
||||
allowNull: true,
|
||||
field: 'bank_name',
|
||||
},
|
||||
iban: {
|
||||
type: DataTypes.STRING(34),
|
||||
allowNull: true,
|
||||
},
|
||||
bic: {
|
||||
type: DataTypes.STRING(11),
|
||||
allowNull: true,
|
||||
},
|
||||
accountType: {
|
||||
type: DataTypes.ENUM('bank', 'cash', 'virtual'),
|
||||
allowNull: false,
|
||||
defaultValue: 'bank',
|
||||
field: 'account_type',
|
||||
},
|
||||
usageType: {
|
||||
type: DataTypes.ENUM('general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash'),
|
||||
allowNull: false,
|
||||
defaultValue: 'general',
|
||||
field: 'usage_type',
|
||||
},
|
||||
currencyCode: {
|
||||
type: DataTypes.STRING(3),
|
||||
allowNull: false,
|
||||
defaultValue: 'EUR',
|
||||
field: 'currency_code',
|
||||
},
|
||||
allowSepaCollections: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'allow_sepa_collections',
|
||||
},
|
||||
allowOutgoingPayments: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
field: 'allow_outgoing_payments',
|
||||
},
|
||||
isDefault: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_default',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive', 'archived'),
|
||||
allowNull: false,
|
||||
defaultValue: 'active',
|
||||
},
|
||||
notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'archived_at',
|
||||
},
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'sort_order',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_accounts',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubAccount;
|
||||
87
backend/models/ClubAccountTransaction.js
Normal file
87
backend/models/ClubAccountTransaction.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubAccountTransaction = sequelize.define('ClubAccountTransaction', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
accountId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'account_id',
|
||||
},
|
||||
invoiceId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'invoice_id',
|
||||
},
|
||||
paymentClaimId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'payment_claim_id',
|
||||
},
|
||||
direction: {
|
||||
type: DataTypes.ENUM('credit', 'debit'),
|
||||
allowNull: false,
|
||||
defaultValue: 'credit',
|
||||
},
|
||||
bookingType: {
|
||||
type: DataTypes.ENUM('manual', 'invoice', 'adjustment', 'payment_claim'),
|
||||
allowNull: false,
|
||||
defaultValue: 'manual',
|
||||
field: 'booking_type',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('planned', 'booked', 'cancelled'),
|
||||
allowNull: false,
|
||||
defaultValue: 'booked',
|
||||
},
|
||||
bookingDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
field: 'booking_date',
|
||||
},
|
||||
valueDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'value_date',
|
||||
},
|
||||
amountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'amount_cents',
|
||||
},
|
||||
currencyCode: {
|
||||
type: DataTypes.STRING(3),
|
||||
allowNull: false,
|
||||
defaultValue: 'EUR',
|
||||
field: 'currency_code',
|
||||
},
|
||||
reference: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_account_transactions',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubAccountTransaction;
|
||||
73
backend/models/ClubCommunicationDeliveryLog.js
Normal file
73
backend/models/ClubCommunicationDeliveryLog.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationDeliveryLog = sequelize.define('ClubCommunicationDeliveryLog', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
threadId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'thread_id',
|
||||
},
|
||||
recipientId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'recipient_id',
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('sent', 'failed', 'skipped'),
|
||||
allowNull: false,
|
||||
defaultValue: 'failed',
|
||||
},
|
||||
attemptNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'attempt_no',
|
||||
},
|
||||
retryable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
errorCode: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'error_code',
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'error_message',
|
||||
},
|
||||
transportMessageId: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'transport_message_id',
|
||||
},
|
||||
transportResponse: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'transport_response',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_delivery_logs',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationDeliveryLog;
|
||||
47
backend/models/ClubCommunicationMessage.js
Normal file
47
backend/models/ClubCommunicationMessage.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationMessage = sequelize.define('ClubCommunicationMessage', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
threadId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'thread_id',
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
messageType: {
|
||||
type: DataTypes.ENUM('message', 'note'),
|
||||
allowNull: false,
|
||||
defaultValue: 'message',
|
||||
field: 'message_type',
|
||||
},
|
||||
direction: {
|
||||
type: DataTypes.ENUM('outbound', 'internal', 'inbound'),
|
||||
allowNull: false,
|
||||
defaultValue: 'outbound',
|
||||
},
|
||||
body: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_messages',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationMessage;
|
||||
84
backend/models/ClubCommunicationRecipient.js
Normal file
84
backend/models/ClubCommunicationRecipient.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationRecipient = sequelize.define('ClubCommunicationRecipient', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
threadId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'thread_id',
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
memberId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'member_id',
|
||||
},
|
||||
recipientName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'recipient_name',
|
||||
},
|
||||
emailSnapshot: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'email_snapshot',
|
||||
},
|
||||
deliveryStatus: {
|
||||
type: DataTypes.ENUM('pending', 'sent', 'failed', 'skipped'),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
field: 'delivery_status',
|
||||
},
|
||||
deliveredAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'delivered_at',
|
||||
},
|
||||
lastAttemptAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_attempt_at',
|
||||
},
|
||||
attemptCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'attempt_count',
|
||||
},
|
||||
retryable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
errorCode: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'error_code',
|
||||
},
|
||||
transportMessageId: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'transport_message_id',
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'error_message',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_recipients',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationRecipient;
|
||||
58
backend/models/ClubCommunicationTemplate.js
Normal file
58
backend/models/ClubCommunicationTemplate.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationTemplate = sequelize.define('ClubCommunicationTemplate', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(160),
|
||||
allowNull: false,
|
||||
},
|
||||
category: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
defaultValue: 'general',
|
||||
},
|
||||
subjectTemplate: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'subject_template',
|
||||
},
|
||||
bodyTemplate: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
field: 'body_template',
|
||||
},
|
||||
variablesHint: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'variables_hint',
|
||||
},
|
||||
isSystemTemplate: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_system_template',
|
||||
},
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'sort_order',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_templates',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationTemplate;
|
||||
67
backend/models/ClubCommunicationThread.js
Normal file
67
backend/models/ClubCommunicationThread.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationThread = sequelize.define('ClubCommunicationThread', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
threadType: {
|
||||
type: DataTypes.ENUM('direct', 'group', 'broadcast'),
|
||||
allowNull: false,
|
||||
defaultValue: 'direct',
|
||||
field: 'thread_type',
|
||||
},
|
||||
subject: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('draft', 'scheduled', 'sent', 'archived'),
|
||||
allowNull: false,
|
||||
defaultValue: 'draft',
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
distributionGroupId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'distribution_group_id',
|
||||
},
|
||||
recipientMemberId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'recipient_member_id',
|
||||
},
|
||||
scheduledAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'scheduled_at',
|
||||
},
|
||||
recipientFilters: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'recipient_filters',
|
||||
},
|
||||
sentAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'sent_at',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_threads',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationThread;
|
||||
47
backend/models/ClubDistributionGroup.js
Normal file
47
backend/models/ClubDistributionGroup.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDistributionGroup = sequelize.define('ClubDistributionGroup', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
groupType: {
|
||||
type: DataTypes.ENUM('manual', 'training_group', 'team', 'custom'),
|
||||
allowNull: false,
|
||||
defaultValue: 'manual',
|
||||
field: 'group_type',
|
||||
},
|
||||
isSystemGroup: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_system_group',
|
||||
},
|
||||
filterDefinition: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'filter_definition',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_distribution_groups',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubDistributionGroup;
|
||||
33
backend/models/ClubDistributionGroupMember.js
Normal file
33
backend/models/ClubDistributionGroupMember.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDistributionGroupMember = sequelize.define('ClubDistributionGroupMember', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
groupId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'group_id',
|
||||
},
|
||||
memberId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'member_id',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_distribution_group_members',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['group_id', 'member_id'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDistributionGroupMember;
|
||||
61
backend/models/ClubDocument.js
Normal file
61
backend/models/ClubDocument.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDocument = sequelize.define('ClubDocument', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
documentType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
field: 'document_type',
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'active',
|
||||
},
|
||||
visibilityScope: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'board',
|
||||
field: 'visibility_scope',
|
||||
},
|
||||
ownerUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'owner_user_id',
|
||||
},
|
||||
currentVersionNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'current_version_no',
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'archived_at',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_documents',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['club_id', 'document_type', 'status'] },
|
||||
{ fields: ['club_id', 'visibility_scope'] },
|
||||
{ fields: ['club_id', 'current_version_no'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDocument;
|
||||
35
backend/models/ClubDocumentLink.js
Normal file
35
backend/models/ClubDocumentLink.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDocumentLink = sequelize.define('ClubDocumentLink', {
|
||||
documentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'document_id',
|
||||
},
|
||||
linkedEntityType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
field: 'linked_entity_type',
|
||||
},
|
||||
linkedEntityId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'linked_entity_id',
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_document_links',
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ fields: ['linked_entity_type', 'linked_entity_id'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDocumentLink;
|
||||
65
backend/models/ClubDocumentVersion.js
Normal file
65
backend/models/ClubDocumentVersion.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDocumentVersion = sequelize.define('ClubDocumentVersion', {
|
||||
documentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'document_id',
|
||||
},
|
||||
versionNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'version_no',
|
||||
},
|
||||
fileName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'file_name',
|
||||
},
|
||||
storagePath: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false,
|
||||
field: 'storage_path',
|
||||
},
|
||||
mimeType: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
field: 'mime_type',
|
||||
},
|
||||
fileSizeBytes: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'file_size_bytes',
|
||||
},
|
||||
checksumSha256: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'checksum_sha256',
|
||||
},
|
||||
uploadedByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'uploaded_by_user_id',
|
||||
},
|
||||
uploadedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'uploaded_at',
|
||||
},
|
||||
changeNote: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'change_note',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_document_versions',
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ unique: true, fields: ['document_id', 'version_no'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDocumentVersion;
|
||||
109
backend/models/ClubInvoice.js
Normal file
109
backend/models/ClubInvoice.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubInvoice = sequelize.define('ClubInvoice', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
invoiceDirection: {
|
||||
type: DataTypes.STRING(16),
|
||||
allowNull: false,
|
||||
field: 'invoice_direction',
|
||||
},
|
||||
invoiceType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
field: 'invoice_type',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'draft',
|
||||
},
|
||||
invoiceNumber: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'invoice_number',
|
||||
},
|
||||
externalReference: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'external_reference',
|
||||
},
|
||||
partyId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'party_id',
|
||||
},
|
||||
accountId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'account_id',
|
||||
},
|
||||
issuedOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'issued_on',
|
||||
},
|
||||
dueOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'due_on',
|
||||
},
|
||||
paidOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'paid_on',
|
||||
},
|
||||
netAmountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'net_amount_cents',
|
||||
},
|
||||
taxAmountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'tax_amount_cents',
|
||||
},
|
||||
grossAmountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'gross_amount_cents',
|
||||
},
|
||||
currencyCode: {
|
||||
type: DataTypes.STRING(3),
|
||||
allowNull: false,
|
||||
defaultValue: 'EUR',
|
||||
field: 'currency_code',
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
documentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'document_id',
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'archived_at',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_invoices',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubInvoice;
|
||||
49
backend/models/ClubInvoiceItem.js
Normal file
49
backend/models/ClubInvoiceItem.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubInvoiceItem = sequelize.define('ClubInvoiceItem', {
|
||||
invoiceId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'invoice_id',
|
||||
},
|
||||
lineNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'line_no',
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
quantity: {
|
||||
type: DataTypes.DECIMAL(12, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
},
|
||||
unitPriceCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'unit_price_cents',
|
||||
},
|
||||
taxRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'tax_rate',
|
||||
},
|
||||
totalCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'total_cents',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_invoice_items',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubInvoiceItem;
|
||||
101
backend/models/ClubInvoiceParty.js
Normal file
101
backend/models/ClubInvoiceParty.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubInvoiceParty = sequelize.define('ClubInvoiceParty', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
partyType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'customer',
|
||||
field: 'party_type',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'active',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
contractReference: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
field: 'contract_reference',
|
||||
},
|
||||
validFrom: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'valid_from',
|
||||
},
|
||||
validTo: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'valid_to',
|
||||
},
|
||||
contactName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'contact_name',
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
phone: {
|
||||
type: DataTypes.STRING(80),
|
||||
allowNull: true,
|
||||
},
|
||||
street: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
postalCode: {
|
||||
type: DataTypes.STRING(24),
|
||||
allowNull: true,
|
||||
field: 'postal_code',
|
||||
},
|
||||
city: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
},
|
||||
countryCode: {
|
||||
type: DataTypes.STRING(2),
|
||||
allowNull: true,
|
||||
defaultValue: 'DE',
|
||||
field: 'country_code',
|
||||
},
|
||||
iban: {
|
||||
type: DataTypes.STRING(34),
|
||||
allowNull: true,
|
||||
},
|
||||
bic: {
|
||||
type: DataTypes.STRING(11),
|
||||
allowNull: true,
|
||||
},
|
||||
taxIdentifier: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'tax_identifier',
|
||||
},
|
||||
notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_invoice_parties',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['club_id', 'party_type'] },
|
||||
{ fields: ['club_id', 'status'] },
|
||||
{ fields: ['club_id', 'valid_from'] },
|
||||
{ fields: ['club_id', 'valid_to'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubInvoiceParty;
|
||||
89
backend/models/ClubPaymentClaim.js
Normal file
89
backend/models/ClubPaymentClaim.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubPaymentClaim = sequelize.define('ClubPaymentClaim', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id'
|
||||
},
|
||||
memberId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'member_id'
|
||||
},
|
||||
feeRuleId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'fee_rule_id'
|
||||
},
|
||||
claimType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'membership_fee',
|
||||
field: 'claim_type'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('open', 'partially_paid', 'paid', 'written_off', 'cancelled'),
|
||||
allowNull: false,
|
||||
defaultValue: 'open'
|
||||
},
|
||||
dueOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
field: 'due_on'
|
||||
},
|
||||
amountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'amount_cents'
|
||||
},
|
||||
paidAmountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'paid_amount_cents'
|
||||
},
|
||||
currencyCode: {
|
||||
type: DataTypes.STRING(3),
|
||||
allowNull: false,
|
||||
defaultValue: 'EUR',
|
||||
field: 'currency_code'
|
||||
},
|
||||
reminderLevel: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'reminder_level'
|
||||
},
|
||||
lastReminderAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_reminder_at'
|
||||
},
|
||||
notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
settledAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'settled_at'
|
||||
},
|
||||
lastPaidAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_paid_at'
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'archived_at'
|
||||
}
|
||||
}, {
|
||||
tableName: 'club_payment_claims',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default ClubPaymentClaim;
|
||||
94
backend/models/ClubRequest.js
Normal file
94
backend/models/ClubRequest.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubRequest = sequelize.define('ClubRequest', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id'
|
||||
},
|
||||
requestType: {
|
||||
type: DataTypes.ENUM('contact', 'trial_training', 'membership', 'sponsoring'),
|
||||
allowNull: false,
|
||||
defaultValue: 'contact',
|
||||
field: 'request_type'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('open', 'in_progress', 'waiting', 'converted', 'rejected', 'archived'),
|
||||
allowNull: false,
|
||||
defaultValue: 'open'
|
||||
},
|
||||
workflowStage: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'workflow_stage'
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'),
|
||||
allowNull: false,
|
||||
defaultValue: 'normal'
|
||||
},
|
||||
subject: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
firstName: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'first_name'
|
||||
},
|
||||
lastName: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'last_name'
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
phone: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
sourceSystem: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'source_system'
|
||||
},
|
||||
receivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'received_at'
|
||||
},
|
||||
assignedUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'assigned_user_id'
|
||||
},
|
||||
assignedMemberId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'assigned_member_id'
|
||||
},
|
||||
convertedMemberId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'converted_member_id'
|
||||
},
|
||||
closedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'closed_at'
|
||||
}
|
||||
}, {
|
||||
tableName: 'club_requests',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default ClubRequest;
|
||||
32
backend/models/ClubRequestNote.js
Normal file
32
backend/models/ClubRequestNote.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubRequestNote = sequelize.define('ClubRequestNote', {
|
||||
clubRequestId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_request_id'
|
||||
},
|
||||
noteType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'internal',
|
||||
field: 'note_type'
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id'
|
||||
},
|
||||
body: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
}
|
||||
}, {
|
||||
tableName: 'club_request_notes',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
updatedAt: false
|
||||
});
|
||||
|
||||
export default ClubRequestNote;
|
||||
58
backend/models/ClubRole.js
Normal file
58
backend/models/ClubRole.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubRole = sequelize.define('ClubRole', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
roleKey: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
field: 'role_key',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
permissions: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: {},
|
||||
},
|
||||
isSystemRole: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_system_role',
|
||||
},
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'sort_order',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_roles',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['club_id', 'role_key'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubRole;
|
||||
64
backend/models/ClubSepaMandate.js
Normal file
64
backend/models/ClubSepaMandate.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubSepaMandate = sequelize.define('ClubSepaMandate', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id'
|
||||
},
|
||||
memberId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'member_id'
|
||||
},
|
||||
debtorName: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
field: 'debtor_name'
|
||||
},
|
||||
iban: {
|
||||
type: DataTypes.STRING(34),
|
||||
allowNull: false
|
||||
},
|
||||
bic: {
|
||||
type: DataTypes.STRING(11),
|
||||
allowNull: true
|
||||
},
|
||||
mandateReference: {
|
||||
type: DataTypes.STRING(80),
|
||||
allowNull: false,
|
||||
field: 'mandate_reference'
|
||||
},
|
||||
signedOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'signed_on'
|
||||
},
|
||||
validFrom: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'valid_from'
|
||||
},
|
||||
revokedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'revoked_at'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'active'
|
||||
},
|
||||
historyNote: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'history_note'
|
||||
}
|
||||
}, {
|
||||
tableName: 'club_sepa_mandates',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default ClubSepaMandate;
|
||||
94
backend/models/ClubTask.js
Normal file
94
backend/models/ClubTask.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubTask = sequelize.define('ClubTask', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id'
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
taskType: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'task_type'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('open', 'in_progress', 'waiting', 'done', 'cancelled', 'archived'),
|
||||
allowNull: false,
|
||||
defaultValue: 'open'
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.ENUM('low', 'normal', 'high', 'urgent'),
|
||||
allowNull: false,
|
||||
defaultValue: 'normal'
|
||||
},
|
||||
dueAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'due_at'
|
||||
},
|
||||
remindAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'remind_at'
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id'
|
||||
},
|
||||
assignedUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'assigned_user_id'
|
||||
},
|
||||
automationSource: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'automation_source'
|
||||
},
|
||||
automationKey: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'automation_key'
|
||||
},
|
||||
relatedEntityType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: true,
|
||||
field: 'related_entity_type'
|
||||
},
|
||||
relatedEntityId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'related_entity_id'
|
||||
},
|
||||
completedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'completed_at'
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'archived_at'
|
||||
},
|
||||
sourceSnapshot: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'source_snapshot'
|
||||
}
|
||||
}, {
|
||||
tableName: 'club_tasks',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default ClubTask;
|
||||
31
backend/models/ClubTaskSuppression.js
Normal file
31
backend/models/ClubTaskSuppression.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubTaskSuppression = sequelize.define('ClubTaskSuppression', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id'
|
||||
},
|
||||
automationKey: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'automation_key'
|
||||
},
|
||||
suppressionToken: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'suppression_token'
|
||||
},
|
||||
dismissedByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'dismissed_by_user_id'
|
||||
}
|
||||
}, {
|
||||
tableName: 'club_task_suppressions',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default ClubTaskSuppression;
|
||||
44
backend/models/ClubUserRole.js
Normal file
44
backend/models/ClubUserRole.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubUserRole = sequelize.define('ClubUserRole', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'user_id',
|
||||
},
|
||||
clubRoleId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_role_id',
|
||||
},
|
||||
isPrimary: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_primary',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_user_roles',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['club_id', 'user_id', 'club_role_id'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubUserRole;
|
||||
@@ -123,6 +123,60 @@ const Member = sequelize.define('Member', {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
},
|
||||
membershipStatus: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'active',
|
||||
field: 'membership_status',
|
||||
},
|
||||
membershipType: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'regular',
|
||||
field: 'membership_type',
|
||||
},
|
||||
joinedOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'joined_on',
|
||||
},
|
||||
leftOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'left_on',
|
||||
},
|
||||
contributionGroupCode: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'contribution_group_code',
|
||||
},
|
||||
needsSepaMandate: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'needs_sepa_mandate',
|
||||
},
|
||||
sepaMandateReference: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'sepa_mandate_reference',
|
||||
},
|
||||
isArchived: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_archived',
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'archived_at',
|
||||
},
|
||||
archivedReason: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'archived_reason',
|
||||
},
|
||||
active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
|
||||
@@ -57,6 +57,9 @@ import BillingRun from './BillingRun.js';
|
||||
import BillingDocument from './BillingDocument.js';
|
||||
import BillingDocumentValue from './BillingDocumentValue.js';
|
||||
import BillingUserSetting from './BillingUserSetting.js';
|
||||
import ClubDocument from './ClubDocument.js';
|
||||
import ClubDocumentVersion from './ClubDocumentVersion.js';
|
||||
import ClubDocumentLink from './ClubDocumentLink.js';
|
||||
import FriendlyMatch from './FriendlyMatch.js';
|
||||
import FriendlyMatchShared from './FriendlyMatchShared.js';
|
||||
import FriendlyMatchInvitation from './FriendlyMatchInvitation.js';
|
||||
@@ -70,6 +73,26 @@ import MemberTrainingGroup from './MemberTrainingGroup.js';
|
||||
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
|
||||
import TrainingTime from './TrainingTime.js';
|
||||
import ClubVenue from './ClubVenue.js';
|
||||
import ClubRequest from './ClubRequest.js';
|
||||
import ClubRequestNote from './ClubRequestNote.js';
|
||||
import ClubSepaMandate from './ClubSepaMandate.js';
|
||||
import ClubPaymentClaim from './ClubPaymentClaim.js';
|
||||
import ClubAccount from './ClubAccount.js';
|
||||
import ClubAccountTransaction from './ClubAccountTransaction.js';
|
||||
import ClubInvoiceParty from './ClubInvoiceParty.js';
|
||||
import ClubInvoice from './ClubInvoice.js';
|
||||
import ClubInvoiceItem from './ClubInvoiceItem.js';
|
||||
import ClubTask from './ClubTask.js';
|
||||
import ClubTaskSuppression from './ClubTaskSuppression.js';
|
||||
import ClubRole from './ClubRole.js';
|
||||
import ClubUserRole from './ClubUserRole.js';
|
||||
import ClubCommunicationThread from './ClubCommunicationThread.js';
|
||||
import ClubCommunicationMessage from './ClubCommunicationMessage.js';
|
||||
import ClubCommunicationRecipient from './ClubCommunicationRecipient.js';
|
||||
import ClubCommunicationDeliveryLog from './ClubCommunicationDeliveryLog.js';
|
||||
import ClubCommunicationTemplate from './ClubCommunicationTemplate.js';
|
||||
import ClubDistributionGroup from './ClubDistributionGroup.js';
|
||||
import ClubDistributionGroupMember from './ClubDistributionGroupMember.js';
|
||||
// Official tournaments relations
|
||||
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
|
||||
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
|
||||
@@ -172,11 +195,22 @@ ClubTeam.belongsTo(League, { foreignKey: 'leagueId', as: 'league' });
|
||||
|
||||
Season.hasMany(ClubTeam, { foreignKey: 'seasonId', as: 'clubTeams' });
|
||||
ClubTeam.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' });
|
||||
ClubTeam.hasMany(ClubTeamMember, { foreignKey: 'clubTeamId', as: 'lineupMembers' });
|
||||
ClubTeamMember.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
|
||||
Member.hasMany(ClubTeamMember, { foreignKey: 'memberId', as: 'clubTeamAssignments' });
|
||||
ClubTeamMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member' });
|
||||
|
||||
// TeamDocument relationships
|
||||
ClubTeam.hasMany(TeamDocument, { foreignKey: 'clubTeamId', as: 'documents' });
|
||||
TeamDocument.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' });
|
||||
|
||||
Club.hasMany(ClubDocument, { foreignKey: 'clubId', as: 'documents' });
|
||||
ClubDocument.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubDocument.hasMany(ClubDocumentVersion, { foreignKey: 'documentId', as: 'versions' });
|
||||
ClubDocumentVersion.belongsTo(ClubDocument, { foreignKey: 'documentId', as: 'document' });
|
||||
ClubDocument.hasMany(ClubDocumentLink, { foreignKey: 'documentId', as: 'links' });
|
||||
ClubDocumentLink.belongsTo(ClubDocument, { foreignKey: 'documentId', as: 'document' });
|
||||
|
||||
Match.belongsTo(Location, { foreignKey: 'locationId', as: 'location' });
|
||||
Location.hasMany(Match, { foreignKey: 'locationId', as: 'matches' });
|
||||
|
||||
@@ -447,6 +481,114 @@ FriendlyMatchInvitation.hasOne(FriendlyMatchShared, {
|
||||
Club.hasMany(ClubVenue, { foreignKey: 'clubId', as: 'venues' });
|
||||
ClubVenue.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
|
||||
Club.hasMany(ClubRequest, { foreignKey: 'clubId', as: 'clubRequests' });
|
||||
ClubRequest.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubRequest.hasMany(ClubRequestNote, { foreignKey: 'clubRequestId', as: 'notes' });
|
||||
ClubRequestNote.belongsTo(ClubRequest, { foreignKey: 'clubRequestId', as: 'request' });
|
||||
User.hasMany(ClubRequest, { foreignKey: 'assignedUserId', as: 'assignedClubRequests' });
|
||||
ClubRequest.belongsTo(User, { foreignKey: 'assignedUserId', as: 'assignedUser', constraints: false });
|
||||
Member.hasMany(ClubRequest, { foreignKey: 'assignedMemberId', as: 'assignedRequests' });
|
||||
ClubRequest.belongsTo(Member, { foreignKey: 'assignedMemberId', as: 'assignedMember', constraints: false });
|
||||
User.hasMany(ClubRequestNote, { foreignKey: 'createdByUserId', as: 'clubRequestNotes' });
|
||||
ClubRequestNote.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
|
||||
Club.hasMany(ClubSepaMandate, { foreignKey: 'clubId', as: 'sepaMandates' });
|
||||
ClubSepaMandate.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Member.hasMany(ClubSepaMandate, { foreignKey: 'memberId', as: 'sepaMandates' });
|
||||
ClubSepaMandate.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
|
||||
|
||||
Club.hasMany(ClubPaymentClaim, { foreignKey: 'clubId', as: 'paymentClaims' });
|
||||
ClubPaymentClaim.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Member.hasMany(ClubPaymentClaim, { foreignKey: 'memberId', as: 'paymentClaims' });
|
||||
ClubPaymentClaim.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
|
||||
ClubPaymentClaim.hasMany(ClubAccountTransaction, { foreignKey: 'paymentClaimId', as: 'transactions' });
|
||||
ClubAccountTransaction.belongsTo(ClubPaymentClaim, { foreignKey: 'paymentClaimId', as: 'paymentClaim', constraints: false });
|
||||
|
||||
Club.hasMany(ClubAccount, { foreignKey: 'clubId', as: 'accounts' });
|
||||
ClubAccount.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Club.hasMany(ClubAccountTransaction, { foreignKey: 'clubId', as: 'accountTransactions' });
|
||||
ClubAccountTransaction.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubAccount.hasMany(ClubAccountTransaction, { foreignKey: 'accountId', as: 'transactions' });
|
||||
ClubAccountTransaction.belongsTo(ClubAccount, { foreignKey: 'accountId', as: 'account' });
|
||||
|
||||
Club.hasMany(ClubInvoiceParty, { foreignKey: 'clubId', as: 'invoiceParties' });
|
||||
ClubInvoiceParty.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
|
||||
Club.hasMany(ClubInvoice, { foreignKey: 'clubId', as: 'invoices' });
|
||||
ClubInvoice.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubInvoice.belongsTo(ClubInvoiceParty, { foreignKey: 'partyId', as: 'party', constraints: false });
|
||||
ClubInvoiceParty.hasMany(ClubInvoice, { foreignKey: 'partyId', as: 'invoices' });
|
||||
ClubInvoice.belongsTo(ClubAccount, { foreignKey: 'accountId', as: 'account', constraints: false });
|
||||
ClubAccount.hasMany(ClubInvoice, { foreignKey: 'accountId', as: 'invoices' });
|
||||
User.hasMany(ClubInvoice, { foreignKey: 'createdByUserId', as: 'createdInvoices' });
|
||||
ClubInvoice.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
ClubInvoice.hasMany(ClubAccountTransaction, { foreignKey: 'invoiceId', as: 'transactions' });
|
||||
ClubAccountTransaction.belongsTo(ClubInvoice, { foreignKey: 'invoiceId', as: 'invoice', constraints: false });
|
||||
User.hasMany(ClubAccountTransaction, { foreignKey: 'createdByUserId', as: 'createdAccountTransactions' });
|
||||
ClubAccountTransaction.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
|
||||
ClubInvoice.hasMany(ClubInvoiceItem, { foreignKey: 'invoiceId', as: 'items' });
|
||||
ClubInvoiceItem.belongsTo(ClubInvoice, { foreignKey: 'invoiceId', as: 'invoice' });
|
||||
|
||||
Club.hasMany(ClubTask, { foreignKey: 'clubId', as: 'clubTasks' });
|
||||
ClubTask.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
User.hasMany(ClubTask, { foreignKey: 'createdByUserId', as: 'createdClubTasks' });
|
||||
ClubTask.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
User.hasMany(ClubTask, { foreignKey: 'assignedUserId', as: 'assignedClubTasksWork' });
|
||||
ClubTask.belongsTo(User, { foreignKey: 'assignedUserId', as: 'assignedUser', constraints: false });
|
||||
Club.hasMany(ClubTaskSuppression, { foreignKey: 'clubId', as: 'taskSuppressions' });
|
||||
ClubTaskSuppression.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
User.hasMany(ClubTaskSuppression, { foreignKey: 'dismissedByUserId', as: 'dismissedTaskSuggestions' });
|
||||
ClubTaskSuppression.belongsTo(User, { foreignKey: 'dismissedByUserId', as: 'dismissedByUser', constraints: false });
|
||||
|
||||
Club.hasMany(ClubRole, { foreignKey: 'clubId', as: 'clubRoles' });
|
||||
ClubRole.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubRole.hasMany(ClubUserRole, { foreignKey: 'clubRoleId', as: 'assignments' });
|
||||
ClubUserRole.belongsTo(ClubRole, { foreignKey: 'clubRoleId', as: 'role' });
|
||||
Club.hasMany(ClubUserRole, { foreignKey: 'clubId', as: 'clubUserRoles' });
|
||||
ClubUserRole.belongsTo(Club, { foreignKey: 'clubId', as: 'club', constraints: false });
|
||||
User.hasMany(ClubUserRole, { foreignKey: 'userId', as: 'clubRoleAssignments' });
|
||||
ClubUserRole.belongsTo(User, { foreignKey: 'userId', as: 'user', constraints: false });
|
||||
|
||||
Club.hasMany(ClubCommunicationThread, { foreignKey: 'clubId', as: 'communicationThreads' });
|
||||
ClubCommunicationThread.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
User.hasMany(ClubCommunicationThread, { foreignKey: 'createdByUserId', as: 'createdCommunicationThreads' });
|
||||
ClubCommunicationThread.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
ClubCommunicationThread.belongsTo(ClubDistributionGroup, { foreignKey: 'distributionGroupId', as: 'distributionGroup', constraints: false });
|
||||
ClubDistributionGroup.hasMany(ClubCommunicationThread, { foreignKey: 'distributionGroupId', as: 'threads' });
|
||||
ClubCommunicationThread.belongsTo(Member, { foreignKey: 'recipientMemberId', as: 'recipientMember', constraints: false });
|
||||
Member.hasMany(ClubCommunicationThread, { foreignKey: 'recipientMemberId', as: 'communicationThreads' });
|
||||
|
||||
Club.hasMany(ClubCommunicationMessage, { foreignKey: 'clubId', as: 'communicationMessages' });
|
||||
ClubCommunicationMessage.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubCommunicationThread.hasMany(ClubCommunicationMessage, { foreignKey: 'threadId', as: 'messages' });
|
||||
ClubCommunicationMessage.belongsTo(ClubCommunicationThread, { foreignKey: 'threadId', as: 'thread' });
|
||||
User.hasMany(ClubCommunicationMessage, { foreignKey: 'createdByUserId', as: 'createdCommunicationMessages' });
|
||||
ClubCommunicationMessage.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
Club.hasMany(ClubCommunicationTemplate, { foreignKey: 'clubId', as: 'communicationTemplates' });
|
||||
ClubCommunicationTemplate.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
Club.hasMany(ClubCommunicationRecipient, { foreignKey: 'clubId', as: 'communicationRecipients' });
|
||||
ClubCommunicationRecipient.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubCommunicationThread.hasMany(ClubCommunicationRecipient, { foreignKey: 'threadId', as: 'recipients' });
|
||||
ClubCommunicationRecipient.belongsTo(ClubCommunicationThread, { foreignKey: 'threadId', as: 'thread' });
|
||||
Member.hasMany(ClubCommunicationRecipient, { foreignKey: 'memberId', as: 'communicationRecipientEntries' });
|
||||
ClubCommunicationRecipient.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
|
||||
Club.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'clubId', as: 'communicationDeliveryLogs' });
|
||||
ClubCommunicationDeliveryLog.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubCommunicationThread.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'threadId', as: 'deliveryLogs' });
|
||||
ClubCommunicationDeliveryLog.belongsTo(ClubCommunicationThread, { foreignKey: 'threadId', as: 'thread' });
|
||||
ClubCommunicationRecipient.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'recipientId', as: 'deliveryLogs' });
|
||||
ClubCommunicationDeliveryLog.belongsTo(ClubCommunicationRecipient, { foreignKey: 'recipientId', as: 'recipient', constraints: false });
|
||||
User.hasMany(ClubCommunicationDeliveryLog, { foreignKey: 'createdByUserId', as: 'createdCommunicationDeliveryLogs' });
|
||||
ClubCommunicationDeliveryLog.belongsTo(User, { foreignKey: 'createdByUserId', as: 'createdByUser', constraints: false });
|
||||
|
||||
Club.hasMany(ClubDistributionGroup, { foreignKey: 'clubId', as: 'distributionGroups' });
|
||||
ClubDistributionGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
|
||||
ClubDistributionGroup.hasMany(ClubDistributionGroupMember, { foreignKey: 'groupId', as: 'memberships' });
|
||||
ClubDistributionGroupMember.belongsTo(ClubDistributionGroup, { foreignKey: 'groupId', as: 'group' });
|
||||
Member.hasMany(ClubDistributionGroupMember, { foreignKey: 'memberId', as: 'distributionGroupMemberships' });
|
||||
ClubDistributionGroupMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member', constraints: false });
|
||||
|
||||
export {
|
||||
User,
|
||||
Log,
|
||||
@@ -473,6 +615,8 @@ export {
|
||||
ClubTeam,
|
||||
ClubTeamMember,
|
||||
TeamDocument,
|
||||
Season,
|
||||
Location,
|
||||
Group,
|
||||
GroupActivity,
|
||||
Tournament,
|
||||
@@ -483,6 +627,8 @@ export {
|
||||
TournamentResult,
|
||||
ExternalTournamentParticipant,
|
||||
TournamentPairing,
|
||||
TournamentStage,
|
||||
TournamentStageAdvancement,
|
||||
Accident,
|
||||
UserToken,
|
||||
OfficialTournament,
|
||||
@@ -504,6 +650,9 @@ export {
|
||||
BillingDocument,
|
||||
BillingDocumentValue,
|
||||
BillingUserSetting,
|
||||
ClubDocument,
|
||||
ClubDocumentVersion,
|
||||
ClubDocumentLink,
|
||||
FriendlyMatch,
|
||||
FriendlyMatchShared,
|
||||
FriendlyMatchInvitation,
|
||||
@@ -517,4 +666,24 @@ export {
|
||||
ClubDisabledPresetGroup,
|
||||
TrainingTime,
|
||||
ClubVenue,
|
||||
ClubRequest,
|
||||
ClubRequestNote,
|
||||
ClubSepaMandate,
|
||||
ClubPaymentClaim,
|
||||
ClubAccount,
|
||||
ClubAccountTransaction,
|
||||
ClubInvoiceParty,
|
||||
ClubInvoice,
|
||||
ClubInvoiceItem,
|
||||
ClubTask,
|
||||
ClubTaskSuppression,
|
||||
ClubRole,
|
||||
ClubUserRole,
|
||||
ClubCommunicationThread,
|
||||
ClubCommunicationMessage,
|
||||
ClubCommunicationRecipient,
|
||||
ClubCommunicationDeliveryLog,
|
||||
ClubCommunicationTemplate,
|
||||
ClubDistributionGroup,
|
||||
ClubDistributionGroupMember,
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createClubCalendarEvent,
|
||||
deleteClubCalendarEvent,
|
||||
listClubCalendarEvents,
|
||||
updateClubCalendarEvent,
|
||||
} from '../controllers/calendarEventController.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -11,6 +12,7 @@ router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', listClubCalendarEvents);
|
||||
router.post('/:clubId', createClubCalendarEvent);
|
||||
router.put('/:clubId/:eventId', updateClubCalendarEvent);
|
||||
router.delete('/:clubId/:eventId', deleteClubCalendarEvent);
|
||||
|
||||
export default router;
|
||||
|
||||
19
backend/routes/clubAccountRoutes.js
Normal file
19
backend/routes/clubAccountRoutes.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import express from 'express';
|
||||
import clubAccountController from '../controllers/clubAccountController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', authorize('finance_accounts', 'read'), clubAccountController.listClubAccounts);
|
||||
router.post('/:clubId', authorize('finance_accounts', 'write'), clubAccountController.createClubAccount);
|
||||
router.put('/:clubId/:accountId', authorize('finance_accounts', 'write'), clubAccountController.updateClubAccount);
|
||||
router.patch('/:clubId/:accountId/status', authorize('finance_accounts', 'write'), clubAccountController.updateClubAccountStatus);
|
||||
router.delete('/:clubId/:accountId', authorize('finance_accounts', 'write'), clubAccountController.deleteClubAccount);
|
||||
router.post('/:clubId/transactions', authorize('finance_accounts', 'write'), clubAccountController.createTransaction);
|
||||
router.put('/:clubId/transactions/:transactionId', authorize('finance_accounts', 'write'), clubAccountController.updateTransaction);
|
||||
router.delete('/:clubId/transactions/:transactionId', authorize('finance_accounts', 'write'), clubAccountController.deleteTransaction);
|
||||
|
||||
export default router;
|
||||
11
backend/routes/clubArchiveRoutes.js
Normal file
11
backend/routes/clubArchiveRoutes.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import express from 'express';
|
||||
import clubArchiveController from '../controllers/clubArchiveController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.get('/:clubId', authorize('archive', 'read'), clubArchiveController.getClubArchive);
|
||||
|
||||
export default router;
|
||||
24
backend/routes/clubCommunicationRoutes.js
Normal file
24
backend/routes/clubCommunicationRoutes.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
import clubCommunicationController from '../controllers/clubCommunicationController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', authorize('communication', 'read'), clubCommunicationController.list);
|
||||
router.post('/:clubId/threads', authorize('communication', 'write'), clubCommunicationController.createThread);
|
||||
router.put('/:clubId/threads/:threadId', authorize('communication', 'write'), clubCommunicationController.updateThread);
|
||||
router.delete('/:clubId/threads/:threadId', authorize('communication', 'write'), clubCommunicationController.deleteThread);
|
||||
router.post('/:clubId/threads/:threadId/messages', authorize('communication', 'write'), clubCommunicationController.addMessage);
|
||||
router.patch('/:clubId/threads/:threadId/send', authorize('communication', 'write'), clubCommunicationController.sendThread);
|
||||
|
||||
router.post('/:clubId/groups', authorize('communication', 'write'), clubCommunicationController.createGroup);
|
||||
router.put('/:clubId/groups/:groupId', authorize('communication', 'write'), clubCommunicationController.updateGroup);
|
||||
router.delete('/:clubId/groups/:groupId', authorize('communication', 'write'), clubCommunicationController.deleteGroup);
|
||||
router.post('/:clubId/templates', authorize('communication', 'write'), clubCommunicationController.createTemplate);
|
||||
router.put('/:clubId/templates/:templateId', authorize('communication', 'write'), clubCommunicationController.updateTemplate);
|
||||
router.delete('/:clubId/templates/:templateId', authorize('communication', 'write'), clubCommunicationController.deleteTemplate);
|
||||
|
||||
export default router;
|
||||
10
backend/routes/clubDashboardRoutes.js
Normal file
10
backend/routes/clubDashboardRoutes.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
import { getClubDashboard } from '../controllers/clubDashboardController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:clubId', authenticate, authorize('members', 'read'), getClubDashboard);
|
||||
|
||||
export default router;
|
||||
19
backend/routes/clubDocumentRoutes.js
Normal file
19
backend/routes/clubDocumentRoutes.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
import clubDocumentController, {
|
||||
uploadMiddleware,
|
||||
} from '../controllers/clubDocumentController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', authorize('settings', 'read'), clubDocumentController.listClubDocuments);
|
||||
router.get('/:clubId/:documentId/download', authorize('settings', 'read'), clubDocumentController.downloadClubDocument);
|
||||
router.post('/:clubId', authorize('settings', 'write'), uploadMiddleware, clubDocumentController.createClubDocument);
|
||||
router.put('/:clubId/:documentId', authorize('settings', 'write'), uploadMiddleware, clubDocumentController.updateClubDocument);
|
||||
router.patch('/:clubId/:documentId/archive', authorize('settings', 'write'), clubDocumentController.archiveClubDocument);
|
||||
router.delete('/:clubId/:documentId', authorize('settings', 'write'), clubDocumentController.deleteClubDocument);
|
||||
|
||||
export default router;
|
||||
21
backend/routes/clubInvoiceRoutes.js
Normal file
21
backend/routes/clubInvoiceRoutes.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import express from 'express';
|
||||
import clubInvoiceController from '../controllers/clubInvoiceController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', authorize('finance_invoices', 'read'), clubInvoiceController.listClubInvoices);
|
||||
|
||||
router.post('/:clubId/parties', authorize('finance_invoices', 'write'), clubInvoiceController.createInvoiceParty);
|
||||
router.put('/:clubId/parties/:partyId', authorize('finance_invoices', 'write'), clubInvoiceController.updateInvoiceParty);
|
||||
router.delete('/:clubId/parties/:partyId', authorize('finance_invoices', 'write'), clubInvoiceController.deleteInvoiceParty);
|
||||
|
||||
router.post('/:clubId', authorize('finance_invoices', 'write'), clubInvoiceController.createInvoice);
|
||||
router.put('/:clubId/:invoiceId', authorize('finance_invoices', 'write'), clubInvoiceController.updateInvoice);
|
||||
router.patch('/:clubId/:invoiceId/status', authorize('finance_invoices', 'write'), clubInvoiceController.updateInvoiceStatus);
|
||||
router.delete('/:clubId/:invoiceId', authorize('finance_invoices', 'write'), clubInvoiceController.deleteInvoice);
|
||||
|
||||
export default router;
|
||||
17
backend/routes/clubPaymentClaimRoutes.js
Normal file
17
backend/routes/clubPaymentClaimRoutes.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import express from 'express';
|
||||
import clubPaymentClaimController from '../controllers/clubPaymentClaimController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/:clubId', authorize('finance_accounts', 'read'), clubPaymentClaimController.list);
|
||||
router.post('/:clubId', authorize('finance_accounts', 'write'), clubPaymentClaimController.create);
|
||||
router.put('/:clubId/:claimId', authorize('finance_accounts', 'write'), clubPaymentClaimController.update);
|
||||
router.patch('/:clubId/:claimId/payment', authorize('finance_accounts', 'write'), clubPaymentClaimController.registerPayment);
|
||||
router.patch('/:clubId/:claimId/status', authorize('finance_accounts', 'write'), clubPaymentClaimController.updateStatus);
|
||||
router.delete('/:clubId/:claimId', authorize('finance_accounts', 'write'), clubPaymentClaimController.delete);
|
||||
|
||||
export default router;
|
||||
20
backend/routes/clubRequestRoutes.js
Normal file
20
backend/routes/clubRequestRoutes.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
import {
|
||||
addClubRequestNote,
|
||||
createClubRequest,
|
||||
listClubRequests,
|
||||
updateClubRequest,
|
||||
updateClubRequestStatus,
|
||||
} from '../controllers/clubRequestController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:clubId', authenticate, authorize('requests', 'read'), listClubRequests);
|
||||
router.post('/:clubId', authenticate, authorize('requests', 'write'), createClubRequest);
|
||||
router.put('/:clubId/:requestId', authenticate, authorize('requests', 'write'), updateClubRequest);
|
||||
router.patch('/:clubId/:requestId/status', authenticate, authorize('requests', 'write'), updateClubRequestStatus);
|
||||
router.post('/:clubId/:requestId/notes', authenticate, authorize('requests', 'write'), addClubRequestNote);
|
||||
|
||||
export default router;
|
||||
11
backend/routes/clubStatisticsRoutes.js
Normal file
11
backend/routes/clubStatisticsRoutes.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import express from 'express';
|
||||
import clubStatisticsController from '../controllers/clubStatisticsController.js';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticate);
|
||||
router.get('/:clubId', authorize('statistics', 'read'), clubStatisticsController.getClubStatistics);
|
||||
|
||||
export default router;
|
||||
24
backend/routes/clubTaskRoutes.js
Normal file
24
backend/routes/clubTaskRoutes.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import express from 'express';
|
||||
import { authenticate } from '../middleware/authMiddleware.js';
|
||||
import { authorize } from '../middleware/authorizationMiddleware.js';
|
||||
import {
|
||||
createClubTask,
|
||||
deleteClubTask,
|
||||
dismissAutomatedClubTaskSuggestion,
|
||||
listClubTasks,
|
||||
materializeAutomatedClubTasks,
|
||||
updateClubTask,
|
||||
updateClubTaskStatus,
|
||||
} from '../controllers/clubTaskController.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:clubId', authenticate, authorize('tasks', 'read'), listClubTasks);
|
||||
router.post('/:clubId', authenticate, authorize('tasks', 'write'), createClubTask);
|
||||
router.post('/:clubId/materialize', authenticate, authorize('tasks', 'write'), materializeAutomatedClubTasks);
|
||||
router.post('/:clubId/dismiss-suggestion', authenticate, authorize('tasks', 'write'), dismissAutomatedClubTaskSuggestion);
|
||||
router.put('/:clubId/:taskId', authenticate, authorize('tasks', 'write'), updateClubTask);
|
||||
router.patch('/:clubId/:taskId/status', authenticate, authorize('tasks', 'write'), updateClubTaskStatus);
|
||||
router.delete('/:clubId/:taskId', authenticate, authorize('tasks', 'write'), deleteClubTask);
|
||||
|
||||
export default router;
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
getClubMembers,
|
||||
getWaitingApprovals,
|
||||
setClubMembers,
|
||||
getMemberSepaMandate,
|
||||
saveMemberSepaMandate,
|
||||
getMemberPlayInterests,
|
||||
setMemberPlayInterest,
|
||||
uploadMemberImage,
|
||||
@@ -37,6 +39,8 @@ router.post('/image/:clubId/:memberId/:imageId/primary', authenticate, authorize
|
||||
router.get('/get/:id/:showAll', authenticate, authorize('members', 'read'), getClubMembers);
|
||||
router.get('/gallery/:clubId', authenticate, authorize('members', 'read'), generateMemberGallery);
|
||||
router.post('/set/:id', authenticate, authorize('members', 'write'), setClubMembers);
|
||||
router.get('/sepa/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberSepaMandate);
|
||||
router.put('/sepa/:clubId/:memberId', authenticate, authorize('members', 'write'), saveMemberSepaMandate);
|
||||
router.get('/play-interest/:clubId', authenticate, authorize('members', 'read'), getMemberPlayInterests);
|
||||
router.post('/play-interest/:clubId', authenticate, authorize('members', 'write'), setMemberPlayInterest);
|
||||
router.get('/notapproved/:id', authenticate, authorize('members', 'read'), getWaitingApprovals);
|
||||
|
||||
@@ -22,6 +22,12 @@ router.get('/roles/available', authenticate, permissionController.getAvailableRo
|
||||
// Get permission structure (no club context needed)
|
||||
router.get('/structure/all', authenticate, permissionController.getPermissionStructure);
|
||||
|
||||
// Get and manage club roles
|
||||
router.get('/:clubId/roles', authenticate, authorize('permissions', 'read'), permissionController.getClubRoles);
|
||||
router.post('/:clubId/roles', authenticate, authorize('permissions', 'write'), permissionController.createClubRole);
|
||||
router.put('/:clubId/roles/:roleId', authenticate, authorize('permissions', 'write'), permissionController.updateClubRole);
|
||||
router.delete('/:clubId/roles/:roleId', authenticate, authorize('permissions', 'write'), permissionController.deleteClubRole);
|
||||
|
||||
// Get current user's permissions for a club (no authorization check - needed to load permissions)
|
||||
router.get('/:clubId', authenticate, permissionController.getUserPermissions);
|
||||
|
||||
@@ -30,6 +36,7 @@ router.get('/:clubId/members', authenticate, authorize('permissions', 'read'), p
|
||||
|
||||
// Update user role (admin only)
|
||||
router.put('/:clubId/user/:userId/role', authenticate, authorize('permissions', 'write'), permissionController.updateUserRole);
|
||||
router.put('/:clubId/user/:userId/roles', authenticate, authorize('permissions', 'write'), permissionController.updateUserRoles);
|
||||
|
||||
// Update user permissions (admin only)
|
||||
router.put('/:clubId/user/:userId/permissions', authenticate, authorize('permissions', 'write'), permissionController.updateUserPermissions);
|
||||
@@ -38,4 +45,3 @@ router.put('/:clubId/user/:userId/permissions', authenticate, authorize('permiss
|
||||
router.put('/:clubId/user/:userId/status', authenticate, authorize('permissions', 'write'), permissionController.updateUserStatus);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory, MemberPlayInterest,
|
||||
MemberOrder, MemberOrderHistory, MemberGroupPhoto, BillingTemplate, BillingTemplateField, BillingRun, BillingDocument, BillingDocumentValue, BillingUserSetting, FriendlyMatch, TrainingCancellation
|
||||
, FriendlyMatchShared, FriendlyMatchInvitation
|
||||
, CalendarEvent, ClubVenue
|
||||
, CalendarEvent, ClubVenue, ClubRequest, ClubRequestNote, ClubSepaMandate, ClubPaymentClaim, ClubAccount, ClubAccountTransaction, ClubInvoiceParty, ClubInvoice, ClubInvoiceItem, ClubRole, ClubUserRole, ClubCommunicationThread, ClubCommunicationMessage, ClubCommunicationRecipient, ClubCommunicationDeliveryLog, ClubCommunicationTemplate, ClubDistributionGroup, ClubDistributionGroupMember
|
||||
} from './models/index.js';
|
||||
import authRoutes from './routes/authRoutes.js';
|
||||
import clubRoutes from './routes/clubRoutes.js';
|
||||
@@ -68,6 +68,16 @@ import friendlyMatchInvitationRoutes from './routes/friendlyMatchInvitationRoute
|
||||
import calendarRoutes from './routes/calendarRoutes.js';
|
||||
import calendarEventRoutes from './routes/calendarEventRoutes.js';
|
||||
import mobileFeedbackRoutes from './routes/mobileFeedbackRoutes.js';
|
||||
import clubRequestRoutes from './routes/clubRequestRoutes.js';
|
||||
import clubDashboardRoutes from './routes/clubDashboardRoutes.js';
|
||||
import clubTaskRoutes from './routes/clubTaskRoutes.js';
|
||||
import clubStatisticsRoutes from './routes/clubStatisticsRoutes.js';
|
||||
import clubArchiveRoutes from './routes/clubArchiveRoutes.js';
|
||||
import clubAccountRoutes from './routes/clubAccountRoutes.js';
|
||||
import clubInvoiceRoutes from './routes/clubInvoiceRoutes.js';
|
||||
import clubPaymentClaimRoutes from './routes/clubPaymentClaimRoutes.js';
|
||||
import clubCommunicationRoutes from './routes/clubCommunicationRoutes.js';
|
||||
import clubDocumentRoutes from './routes/clubDocumentRoutes.js';
|
||||
import schedulerService from './services/schedulerService.js';
|
||||
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
|
||||
import HttpError from './exceptions/HttpError.js';
|
||||
@@ -368,6 +378,16 @@ app.use('/api/friendly-match-invitations', friendlyMatchInvitationRoutes);
|
||||
app.use('/api/calendar', calendarRoutes);
|
||||
app.use('/api/calendar-events', calendarEventRoutes);
|
||||
app.use('/api/mobile-feedback', mobileFeedbackRoutes);
|
||||
app.use('/api/club-requests', clubRequestRoutes);
|
||||
app.use('/api/club-dashboard', clubDashboardRoutes);
|
||||
app.use('/api/club-tasks', clubTaskRoutes);
|
||||
app.use('/api/club-statistics', clubStatisticsRoutes);
|
||||
app.use('/api/club-archive', clubArchiveRoutes);
|
||||
app.use('/api/club-accounts', clubAccountRoutes);
|
||||
app.use('/api/club-invoices', clubInvoiceRoutes);
|
||||
app.use('/api/club-payment-claims', clubPaymentClaimRoutes);
|
||||
app.use('/api/club-communication', clubCommunicationRoutes);
|
||||
app.use('/api/club-documents', clubDocumentRoutes);
|
||||
|
||||
// Middleware für dynamischen kanonischen Tag (vor express.static)
|
||||
const setCanonicalTag = (req, res, next) => {
|
||||
@@ -571,6 +591,20 @@ app.use((err, req, res, next) => {
|
||||
await safeSync(Club);
|
||||
await safeSync(ClubVenue);
|
||||
await safeSync(UserClub);
|
||||
await safeSync(ClubRole);
|
||||
await safeSync(ClubUserRole);
|
||||
await safeSync(ClubAccount);
|
||||
await safeSync(ClubAccountTransaction);
|
||||
await safeSync(ClubInvoiceParty);
|
||||
await safeSync(ClubInvoice);
|
||||
await safeSync(ClubInvoiceItem);
|
||||
await safeSync(ClubDistributionGroup);
|
||||
await safeSync(ClubDistributionGroupMember);
|
||||
await safeSync(ClubCommunicationThread);
|
||||
await safeSync(ClubCommunicationMessage);
|
||||
await safeSync(ClubCommunicationRecipient);
|
||||
await safeSync(ClubCommunicationDeliveryLog);
|
||||
await safeSync(ClubCommunicationTemplate);
|
||||
await safeSync(Log);
|
||||
await safeSync(Member);
|
||||
await safeSync(DiaryDate);
|
||||
|
||||
@@ -17,25 +17,31 @@ class CalendarEventService {
|
||||
});
|
||||
}
|
||||
|
||||
async createClubEvent(userToken, clubId, payload) {
|
||||
async createClubEvent(userToken, clubId, payload, userId = null) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const title = String(payload?.title || '').trim();
|
||||
if (!title) throw new HttpError('Titel fehlt', 400);
|
||||
const startDate = this.normalizeDate(payload?.startDate);
|
||||
const endDate = this.normalizeDate(payload?.endDate || payload?.startDate);
|
||||
if (!startDate || !endDate) throw new HttpError('Ungültiges Datum', 400);
|
||||
if (startDate > endDate) throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400);
|
||||
const normalized = this.normalizePayload(payload);
|
||||
this.validatePayload(normalized);
|
||||
|
||||
return await CalendarEvent.create({
|
||||
clubId,
|
||||
title,
|
||||
startDate,
|
||||
endDate,
|
||||
category: payload?.category ? String(payload.category).trim().slice(0, 64) : null,
|
||||
notes: payload?.notes ? String(payload.notes).trim() : null,
|
||||
...normalized,
|
||||
organizerUserId: Number(userId || payload?.organizerUserId || 0) || null,
|
||||
});
|
||||
}
|
||||
|
||||
async updateClubEvent(userToken, clubId, eventId, payload, userId = null) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
|
||||
if (!event) throw new HttpError('Event nicht gefunden', 404);
|
||||
const normalized = this.normalizePayload(payload);
|
||||
this.validatePayload(normalized);
|
||||
await event.update({
|
||||
...normalized,
|
||||
organizerUserId: Number(userId || payload?.organizerUserId || event.organizerUserId || 0) || null,
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
async deleteClubEvent(userToken, clubId, eventId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const event = await CalendarEvent.findOne({ where: { id: eventId, clubId } });
|
||||
@@ -54,6 +60,34 @@ class CalendarEventService {
|
||||
const text = String(date || '').slice(0, 10);
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : null;
|
||||
}
|
||||
|
||||
normalizePayload(payload = {}) {
|
||||
return {
|
||||
title: String(payload?.title || '').trim().slice(0, 255),
|
||||
eventType: ['club_event', 'meeting', 'tournament', 'social', 'workshop', 'other'].includes(payload?.eventType)
|
||||
? payload.eventType
|
||||
: 'club_event',
|
||||
status: ['planning', 'invited', 'confirmed', 'done', 'cancelled'].includes(payload?.status)
|
||||
? payload.status
|
||||
: 'planning',
|
||||
description: payload?.description ? String(payload.description).trim() : null,
|
||||
location: payload?.location ? String(payload.location).trim().slice(0, 255) : null,
|
||||
startDate: this.normalizeDate(payload?.startDate),
|
||||
endDate: this.normalizeDate(payload?.endDate || payload?.startDate),
|
||||
registrationDeadline: this.normalizeDate(payload?.registrationDeadline),
|
||||
category: payload?.category ? String(payload.category).trim().slice(0, 64) : null,
|
||||
notes: payload?.notes ? String(payload.notes).trim() : null,
|
||||
};
|
||||
}
|
||||
|
||||
validatePayload(payload) {
|
||||
if (!payload.title) throw new HttpError('Titel fehlt', 400);
|
||||
if (!payload.startDate || !payload.endDate) throw new HttpError('Ungültiges Datum', 400);
|
||||
if (payload.startDate > payload.endDate) throw new HttpError('Enddatum darf nicht vor dem Startdatum liegen', 400);
|
||||
if (payload.registrationDeadline && payload.registrationDeadline > payload.startDate) {
|
||||
throw new HttpError('Anmeldefrist darf nicht nach dem Startdatum liegen', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new CalendarEventService();
|
||||
|
||||
594
backend/services/clubAccountService.js
Normal file
594
backend/services/clubAccountService.js
Normal file
@@ -0,0 +1,594 @@
|
||||
import { Op, Transaction } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import ClubAccount from '../models/ClubAccount.js';
|
||||
import ClubAccountTransaction from '../models/ClubAccountTransaction.js';
|
||||
import { ClubPaymentClaim, Member } from '../models/index.js';
|
||||
import clubPaymentClaimService from './clubPaymentClaimService.js';
|
||||
import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js';
|
||||
|
||||
const TRANSACTION_DIRECTIONS = new Set(['credit', 'debit']);
|
||||
const TRANSACTION_BOOKING_TYPES = new Set(['manual', 'invoice', 'adjustment', 'payment_claim']);
|
||||
const TRANSACTION_STATUSES = new Set(['planned', 'booked', 'cancelled']);
|
||||
|
||||
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 normalizeTransactionPayload(payload = {}) {
|
||||
return {
|
||||
accountId: Number(payload.accountId) || null,
|
||||
invoiceId: Number(payload.invoiceId) || null,
|
||||
paymentClaimId: Number(payload.paymentClaimId) || null,
|
||||
direction: TRANSACTION_DIRECTIONS.has(payload.direction) ? payload.direction : 'credit',
|
||||
bookingType: TRANSACTION_BOOKING_TYPES.has(payload.bookingType) ? payload.bookingType : 'manual',
|
||||
status: TRANSACTION_STATUSES.has(payload.status) ? payload.status : 'booked',
|
||||
bookingDate: trimText(payload.bookingDate, 10),
|
||||
valueDate: trimText(payload.valueDate, 10),
|
||||
amountCents: Number.parseInt(payload.amountCents, 10) || 0,
|
||||
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
|
||||
reference: trimText(payload.reference, 255),
|
||||
notes: trimText(payload.notes),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSearchText(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractClaimIdFromReference(reference) {
|
||||
const normalized = normalizeSearchText(reference);
|
||||
const match = normalized.match(/(?:forderung|beitrag|claim)\s*(?:nr|nummer|no)?\s*(\d+)/);
|
||||
if (match) return Number(match[1]) || null;
|
||||
const hashMatch = normalized.match(/#\s*(\d+)/);
|
||||
if (hashMatch) return Number(hashMatch[1]) || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function transactionMatchesClaimReference(transactionText, claim, member) {
|
||||
if (!transactionText) return false;
|
||||
const tokens = [
|
||||
claim?.id ? `forderung ${claim.id}` : '',
|
||||
claim?.id ? `claim ${claim.id}` : '',
|
||||
member?.firstName || '',
|
||||
member?.lastName || '',
|
||||
member?.email || '',
|
||||
member?.sepaMandateReference || member?.sepa_mandate_reference || '',
|
||||
member?.memberNumber || member?.member_number || '',
|
||||
claim?.notes || '',
|
||||
]
|
||||
.map(normalizeSearchText)
|
||||
.filter(Boolean);
|
||||
return tokens.some((token) => token && transactionText.includes(token));
|
||||
}
|
||||
|
||||
function scorePaymentClaimMatch(transaction, claim) {
|
||||
const text = normalizeSearchText([transaction.reference, transaction.notes].filter(Boolean).join(' '));
|
||||
if (!text) return 0;
|
||||
|
||||
let score = 0;
|
||||
const member = claim.member || {};
|
||||
const explicitClaimId = extractClaimIdFromReference(transaction.reference);
|
||||
if (explicitClaimId && Number(explicitClaimId) === Number(claim.id)) {
|
||||
score += 120;
|
||||
} else if (text.includes(`forderung ${claim.id}`) || text.includes(`claim ${claim.id}`) || text.includes(`beitrag ${claim.id}`)) {
|
||||
score += 90;
|
||||
}
|
||||
|
||||
if (transactionMatchesClaimReference(text, claim, member)) {
|
||||
score += 40;
|
||||
}
|
||||
|
||||
const amountCents = Number(transaction.amountCents || 0);
|
||||
const remainingAmountCents = Math.max(0, Number(claim.amountCents || 0) - Number(claim.paidAmountCents || 0));
|
||||
if (amountCents === remainingAmountCents) {
|
||||
score += 35;
|
||||
} else if (amountCents < remainingAmountCents) {
|
||||
score += 15;
|
||||
} else {
|
||||
score -= 20;
|
||||
}
|
||||
|
||||
const bookingDate = transaction.bookingDate ? new Date(transaction.bookingDate) : null;
|
||||
const dueDate = claim.dueOn ? new Date(claim.dueOn) : null;
|
||||
if (bookingDate && dueDate && !Number.isNaN(bookingDate.getTime()) && !Number.isNaN(dueDate.getTime())) {
|
||||
const dayDistance = Math.abs(Math.floor((bookingDate.getTime() - dueDate.getTime()) / 86400000));
|
||||
if (dayDistance <= 7) score += 20;
|
||||
else if (dayDistance <= 30) score += 10;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
async function findBestPaymentClaimMatch(clubId, transaction, dbTransaction = null) {
|
||||
if (!(await hasClubPaymentClaimPaidAmountCentsColumn())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!transaction || transaction.direction !== 'credit' || transaction.status !== 'booked' || Number(transaction.amountCents || 0) <= 0) {
|
||||
return null;
|
||||
}
|
||||
if (transaction.invoiceId || transaction.paymentClaimId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitClaimId = extractClaimIdFromReference(transaction.reference);
|
||||
if (explicitClaimId) {
|
||||
const explicitClaim = await ClubPaymentClaim.findOne({
|
||||
where: {
|
||||
id: explicitClaimId,
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
},
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
if (explicitClaim && Number(transaction.amountCents || 0) <= Math.max(0, Number(explicitClaim.amountCents || 0) - Number(explicitClaim.paidAmountCents || 0))) {
|
||||
return explicitClaim;
|
||||
}
|
||||
}
|
||||
|
||||
const claims = await ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
currencyCode: transaction.currencyCode || 'EUR',
|
||||
},
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
order: [['dueOn', 'ASC'], ['updatedAt', 'ASC']],
|
||||
});
|
||||
|
||||
let bestMatch = null;
|
||||
let bestScore = 0;
|
||||
let secondScore = 0;
|
||||
for (const claim of claims) {
|
||||
const score = scorePaymentClaimMatch(transaction, claim);
|
||||
if (score > bestScore) {
|
||||
secondScore = bestScore;
|
||||
bestScore = score;
|
||||
bestMatch = claim;
|
||||
} else if (score > secondScore) {
|
||||
secondScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestMatch) return null;
|
||||
if (bestScore < 60) return null;
|
||||
if (bestScore < secondScore + 20) return null;
|
||||
if (Number(transaction.amountCents || 0) > Math.max(0, Number(bestMatch.amountCents || 0) - Number(bestMatch.paidAmountCents || 0))) {
|
||||
return null;
|
||||
}
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function buildTransactionIncludes(includePaymentClaims) {
|
||||
return includePaymentClaims
|
||||
? [
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
{ model: ClubPaymentClaim, as: 'paymentClaim', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
]
|
||||
: [
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function validateTransactionPayload(payload) {
|
||||
if (!payload.accountId) {
|
||||
const error = new Error('Für eine Kontenbewegung muss ein Konto gewählt werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!payload.bookingDate) {
|
||||
const error = new Error('Buchungsdatum ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(Number(payload.amountCents)) || Number(payload.amountCents) <= 0) {
|
||||
const error = new Error('Der Betrag muss größer als 0 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) {
|
||||
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
|
||||
const [accounts, transactions] = await Promise.all([
|
||||
ClubAccount.findAll({
|
||||
where: { clubId },
|
||||
order: [
|
||||
['isDefault', 'DESC'],
|
||||
['status', 'ASC'],
|
||||
['sortOrder', 'ASC'],
|
||||
['name', 'ASC'],
|
||||
],
|
||||
}),
|
||||
ClubAccountTransaction.findAll({
|
||||
where: { clubId },
|
||||
include: buildTransactionIncludes(includePaymentClaims),
|
||||
order: [['bookingDate', 'DESC'], ['createdAt', 'DESC']],
|
||||
limit: 250,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
|
||||
async createTransaction(clubId, userId, payload) {
|
||||
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
|
||||
const normalized = normalizeTransactionPayload(payload);
|
||||
if (!includePaymentClaims) {
|
||||
normalized.paymentClaimId = null;
|
||||
}
|
||||
validateTransactionPayload(normalized);
|
||||
|
||||
const account = await ClubAccount.findOne({
|
||||
where: { id: normalized.accountId, clubId },
|
||||
});
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sequelize.transaction(async (dbTransaction) => {
|
||||
let matchedClaim = null;
|
||||
if (includePaymentClaims && normalized.paymentClaimId) {
|
||||
matchedClaim = await ClubPaymentClaim.findOne({
|
||||
where: {
|
||||
id: normalized.paymentClaimId,
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
},
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
transaction: dbTransaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
if (!matchedClaim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
} else if (includePaymentClaims) {
|
||||
matchedClaim = await findBestPaymentClaimMatch(clubId, normalized, dbTransaction);
|
||||
}
|
||||
|
||||
const transactionPayload = {
|
||||
clubId,
|
||||
createdByUserId: userId || null,
|
||||
...normalized,
|
||||
paymentClaimId: includePaymentClaims ? (matchedClaim ? matchedClaim.id : normalized.paymentClaimId) : null,
|
||||
bookingType: matchedClaim ? 'payment_claim' : normalized.bookingType,
|
||||
};
|
||||
|
||||
const transaction = await ClubAccountTransaction.create(transactionPayload, { transaction: dbTransaction });
|
||||
|
||||
if (includePaymentClaims && matchedClaim) {
|
||||
await clubPaymentClaimService.applyPaymentToClaim(
|
||||
clubId,
|
||||
matchedClaim,
|
||||
{
|
||||
amountCents: Number(transaction.amountCents || 0),
|
||||
},
|
||||
dbTransaction
|
||||
);
|
||||
await clubPaymentClaimService.reconcileFromTransactions(clubId, matchedClaim.id, dbTransaction);
|
||||
}
|
||||
|
||||
return transaction.reload({
|
||||
include: buildTransactionIncludes(includePaymentClaims),
|
||||
transaction: dbTransaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateTransaction(clubId, transactionId, payload) {
|
||||
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
|
||||
const transactionRow = await ClubAccountTransaction.findOne({
|
||||
where: { id: transactionId, clubId },
|
||||
});
|
||||
if (!transactionRow) {
|
||||
const error = new Error('Kontenbewegung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (transactionRow.bookingType !== 'manual') {
|
||||
const error = new Error('Automatisch erzeugte Kontenbewegungen können nicht manuell bearbeitet werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeTransactionPayload(payload);
|
||||
if (!includePaymentClaims) {
|
||||
normalized.paymentClaimId = null;
|
||||
}
|
||||
validateTransactionPayload(normalized);
|
||||
const previousPaymentClaimId = Number(transactionRow.paymentClaimId || 0) || null;
|
||||
|
||||
const account = await ClubAccount.findOne({
|
||||
where: { id: normalized.accountId, clubId },
|
||||
});
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await transactionRow.update(normalized);
|
||||
|
||||
if (includePaymentClaims) {
|
||||
const claimIdsToReconcile = new Set([previousPaymentClaimId, normalized.paymentClaimId || null].filter(Boolean));
|
||||
for (const claimId of claimIdsToReconcile) {
|
||||
await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId);
|
||||
}
|
||||
}
|
||||
|
||||
return transactionRow.reload({
|
||||
include: buildTransactionIncludes(includePaymentClaims),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTransaction(clubId, transactionId) {
|
||||
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
|
||||
const transactionRow = await ClubAccountTransaction.findOne({
|
||||
where: { id: transactionId, clubId },
|
||||
});
|
||||
if (!transactionRow) {
|
||||
const error = new Error('Kontenbewegung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (transactionRow.bookingType !== 'manual') {
|
||||
const error = new Error('Automatisch erzeugte Kontenbewegungen können nicht gelöscht werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const claimId = Number(transactionRow.paymentClaimId || 0) || null;
|
||||
await transactionRow.destroy();
|
||||
if (includePaymentClaims && claimId) {
|
||||
await clubPaymentClaimService.reconcileFromTransactions(clubId, claimId);
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async listAccountTransactions(clubId, accountId = null) {
|
||||
const includePaymentClaims = await hasClubPaymentClaimPaidAmountCentsColumn();
|
||||
const where = { clubId };
|
||||
if (accountId) {
|
||||
where.accountId = accountId;
|
||||
}
|
||||
|
||||
return ClubAccountTransaction.findAll({
|
||||
where,
|
||||
include: buildTransactionIncludes(includePaymentClaims),
|
||||
order: [
|
||||
['bookingDate', 'DESC'],
|
||||
['createdAt', 'DESC'],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
159
backend/services/clubArchiveService.js
Normal file
159
backend/services/clubArchiveService.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Op } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import { ClubPaymentClaim, ClubRequest, ClubTask, Member } from '../models/index.js';
|
||||
import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.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 hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn();
|
||||
|
||||
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 = hasPaidAmountCentsColumn
|
||||
? 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();
|
||||
828
backend/services/clubCommunicationService.js
Normal file
828
backend/services/clubCommunicationService.js
Normal file
@@ -0,0 +1,828 @@
|
||||
import sequelize from '../database.js';
|
||||
import {
|
||||
ClubCommunicationThread,
|
||||
ClubCommunicationMessage,
|
||||
ClubCommunicationRecipient,
|
||||
ClubCommunicationDeliveryLog,
|
||||
ClubCommunicationTemplate,
|
||||
ClubDistributionGroup,
|
||||
ClubDistributionGroupMember,
|
||||
ClubSepaMandate,
|
||||
Member,
|
||||
MemberContact,
|
||||
User,
|
||||
} from '../models/index.js';
|
||||
import { sendClubCommunicationEmail } from './emailService.js';
|
||||
|
||||
const THREAD_TYPES = new Set(['direct', 'group', 'broadcast']);
|
||||
const THREAD_STATUSES = new Set(['draft', 'scheduled', 'sent', 'archived']);
|
||||
const MESSAGE_TYPES = new Set(['message', 'note']);
|
||||
const DIRECTIONS = new Set(['outbound', 'internal', 'inbound']);
|
||||
const GROUP_TYPES = new Set(['manual', 'training_group', 'team', 'custom']);
|
||||
|
||||
function trimText(value, maxLength = null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return null;
|
||||
return maxLength ? normalized.slice(0, maxLength) : normalized;
|
||||
}
|
||||
|
||||
function normalizeDate(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeId(value) {
|
||||
const numeric = Number(value);
|
||||
return Number.isInteger(numeric) && numeric > 0 ? numeric : null;
|
||||
}
|
||||
|
||||
function normalizeThreadPayload(payload = {}) {
|
||||
return {
|
||||
threadType: THREAD_TYPES.has(payload.threadType) ? payload.threadType : 'direct',
|
||||
subject: trimText(payload.subject, 255),
|
||||
status: THREAD_STATUSES.has(payload.status) ? payload.status : 'draft',
|
||||
recipientMemberId: normalizeId(payload.recipientMemberId),
|
||||
distributionGroupId: normalizeId(payload.distributionGroupId),
|
||||
scheduledAt: normalizeDate(payload.scheduledAt),
|
||||
recipientFilters: normalizeRecipientFilters(payload.recipientFilters),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGroupPayload(payload = {}) {
|
||||
const memberIds = Array.isArray(payload.memberIds)
|
||||
? [...new Set(payload.memberIds.map(normalizeId).filter(Boolean))]
|
||||
: [];
|
||||
|
||||
return {
|
||||
name: trimText(payload.name, 255),
|
||||
description: trimText(payload.description),
|
||||
groupType: GROUP_TYPES.has(payload.groupType) ? payload.groupType : 'custom',
|
||||
memberIds,
|
||||
filterDefinition: normalizeRecipientFilters(payload.filterDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMessagePayload(payload = {}) {
|
||||
return {
|
||||
body: trimText(payload.body),
|
||||
messageType: MESSAGE_TYPES.has(payload.messageType) ? payload.messageType : 'message',
|
||||
direction: DIRECTIONS.has(payload.direction) ? payload.direction : 'outbound',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRecipientFilters(payload = {}) {
|
||||
return {
|
||||
activeOnly: payload?.activeOnly !== false,
|
||||
onlyWithEmail: Boolean(payload?.onlyWithEmail),
|
||||
missingEmail: Boolean(payload?.missingEmail),
|
||||
requiresSepaMandate: Boolean(payload?.requiresSepaMandate),
|
||||
missingSepaMandate: Boolean(payload?.missingSepaMandate),
|
||||
testMembersOnly: Boolean(payload?.testMembersOnly),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTemplatePayload(payload = {}) {
|
||||
return {
|
||||
name: trimText(payload.name, 160),
|
||||
category: trimText(payload.category, 64) || 'general',
|
||||
subjectTemplate: trimText(payload.subjectTemplate, 255),
|
||||
bodyTemplate: trimText(payload.bodyTemplate),
|
||||
variablesHint: trimText(payload.variablesHint),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureThreadTargets(payload) {
|
||||
if (!payload.subject) {
|
||||
const error = new Error('Betreff ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (payload.threadType === 'direct' && !payload.recipientMemberId) {
|
||||
const error = new Error('Für Einzelnachrichten muss ein Mitglied ausgewählt werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (payload.threadType === 'group' && !payload.distributionGroupId) {
|
||||
const error = new Error('Für Gruppen-Nachrichten muss eine Verteilergruppe ausgewählt werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMemberBelongsToClub(clubId, memberId) {
|
||||
if (!memberId) return;
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId } });
|
||||
if (!member) {
|
||||
const error = new Error('Mitglied wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureGroupBelongsToClub(clubId, groupId) {
|
||||
if (!groupId) return null;
|
||||
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
|
||||
if (!group) {
|
||||
const error = new Error('Verteilergruppe wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
function getMemberDisplayName(member) {
|
||||
return [member?.firstName, member?.lastName].filter(Boolean).join(' ').trim() || `Mitglied ${member?.id || ''}`.trim();
|
||||
}
|
||||
|
||||
function getPrimaryEmail(member) {
|
||||
const contacts = Array.isArray(member?.contacts) ? member.contacts : [];
|
||||
const emailContacts = contacts.filter((contact) => contact?.type === 'email' && contact?.value);
|
||||
const primaryContact = emailContacts.find((contact) => contact.isPrimary) || emailContacts[0];
|
||||
return primaryContact?.value || member?.email || null;
|
||||
}
|
||||
|
||||
function applyRecipientFilters(members, filters = {}, memberIdsWithMandate = new Set()) {
|
||||
const normalized = normalizeRecipientFilters(filters);
|
||||
return (Array.isArray(members) ? members : []).filter((member) => {
|
||||
if (normalized.activeOnly && member?.active === false) return false;
|
||||
if (normalized.testMembersOnly && !member?.testMembership) return false;
|
||||
|
||||
const email = trimText(getPrimaryEmail(member), 255);
|
||||
if (normalized.onlyWithEmail && !email) return false;
|
||||
if (normalized.missingEmail && email) return false;
|
||||
|
||||
const hasMandate = memberIdsWithMandate.has(Number(member?.id));
|
||||
if (normalized.requiresSepaMandate && !hasMandate) return false;
|
||||
if (normalized.missingSepaMandate && hasMandate) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadActiveSepaMemberIds(clubId) {
|
||||
const mandates = await ClubSepaMandate.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active',
|
||||
revokedAt: null,
|
||||
},
|
||||
attributes: ['memberId'],
|
||||
});
|
||||
|
||||
return new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function looksLikeHtml(value = '') {
|
||||
return /<\/?[a-z][\s\S]*>/i.test(String(value || ''));
|
||||
}
|
||||
|
||||
function stripHtml(value = '') {
|
||||
return String(value || '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/p>\s*<p>/gi, '\n\n')
|
||||
.replace(/<\/div>\s*<div>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '\n- ')
|
||||
.replace(/<\/(p|div|li|h[1-6]|blockquote)>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderMessageText(body) {
|
||||
if (looksLikeHtml(body)) {
|
||||
return stripHtml(body);
|
||||
}
|
||||
return String(body || '').trim();
|
||||
}
|
||||
|
||||
function renderMessageHtml(body) {
|
||||
if (looksLikeHtml(body)) {
|
||||
return `<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#1f2937;">${String(body || '')}</div>`;
|
||||
}
|
||||
return `<div style="font-family:Arial,sans-serif;font-size:15px;line-height:1.55;color:#1f2937;white-space:pre-wrap;">${escapeHtml(body).replace(/\n/g, '<br>')}</div>`;
|
||||
}
|
||||
|
||||
function getLatestOutboundMessage(messages = []) {
|
||||
const outboundMessages = messages.filter((message) => message?.messageType === 'message' && message?.direction === 'outbound');
|
||||
return outboundMessages.at(-1) || null;
|
||||
}
|
||||
|
||||
function classifyDeliveryError(error) {
|
||||
const responseCode = Number(error?.responseCode || error?.response?.statusCode || 0) || null;
|
||||
const code = String(error?.code || '').trim() || `SMTP_${responseCode || 'UNKNOWN'}`;
|
||||
const message = trimText(error?.message, 500) || 'Unbekannter Versandfehler.';
|
||||
const retryableCodes = new Set([
|
||||
'ETIMEDOUT',
|
||||
'ESOCKET',
|
||||
'ECONNECTION',
|
||||
'ECONNRESET',
|
||||
'EAI_AGAIN',
|
||||
'ENOTFOUND',
|
||||
'EMESSAGE',
|
||||
'EMAIL_CONFIG_MISSING',
|
||||
]);
|
||||
const retryableResponseCodes = new Set([421, 425, 429, 450, 451, 452]);
|
||||
const retryable = retryableCodes.has(code) || (responseCode ? retryableResponseCodes.has(responseCode) : false);
|
||||
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
responseCode,
|
||||
};
|
||||
}
|
||||
|
||||
function canAttemptDelivery(recipient) {
|
||||
const hasEmail = !!trimText(recipient?.emailSnapshot, 255);
|
||||
if (!hasEmail) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Keine E-Mail-Adresse hinterlegt.',
|
||||
retryable: false,
|
||||
code: 'MISSING_EMAIL',
|
||||
};
|
||||
}
|
||||
|
||||
if (recipient?.deliveryStatus === 'sent') {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'Empfänger wurde bereits erfolgreich beliefert.',
|
||||
retryable: false,
|
||||
code: 'ALREADY_SENT',
|
||||
};
|
||||
}
|
||||
|
||||
if (recipient?.deliveryStatus === 'failed' && recipient?.retryable === false) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: recipient?.errorMessage || 'Versandfehler ist nicht erneut versendbar.',
|
||||
retryable: false,
|
||||
code: recipient?.errorCode || 'NON_RETRYABLE_FAILURE',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
reason: '',
|
||||
retryable: true,
|
||||
code: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildRecipientRowsForThread(clubId, threadPayload) {
|
||||
let members = [];
|
||||
const memberIdsWithMandate = await loadActiveSepaMemberIds(clubId);
|
||||
|
||||
if (threadPayload.threadType === 'direct' && threadPayload.recipientMemberId) {
|
||||
members = await Member.findAll({
|
||||
where: { clubId, id: threadPayload.recipientMemberId },
|
||||
include: [{ model: MemberContact, as: 'contacts', required: false }],
|
||||
});
|
||||
} else if (threadPayload.threadType === 'group' && threadPayload.distributionGroupId) {
|
||||
const group = await ClubDistributionGroup.findOne({
|
||||
where: { id: threadPayload.distributionGroupId, clubId },
|
||||
});
|
||||
const memberships = await ClubDistributionGroupMember.findAll({
|
||||
where: { groupId: threadPayload.distributionGroupId },
|
||||
include: [{
|
||||
model: Member,
|
||||
as: 'member',
|
||||
required: true,
|
||||
where: { clubId, active: true },
|
||||
include: [{ model: MemberContact, as: 'contacts', required: false }],
|
||||
}],
|
||||
});
|
||||
members = applyRecipientFilters(
|
||||
memberships.map((membership) => membership.member).filter(Boolean),
|
||||
threadPayload.recipientFilters || group?.filterDefinition || {},
|
||||
memberIdsWithMandate
|
||||
);
|
||||
} else if (threadPayload.threadType === 'broadcast') {
|
||||
members = await Member.findAll({
|
||||
where: { clubId, active: true },
|
||||
include: [{ model: MemberContact, as: 'contacts', required: false }],
|
||||
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
|
||||
});
|
||||
members = applyRecipientFilters(members, threadPayload.recipientFilters || {}, memberIdsWithMandate);
|
||||
}
|
||||
|
||||
const uniqueMembers = new Map();
|
||||
members.forEach((member) => {
|
||||
const memberId = Number(member.id);
|
||||
if (!uniqueMembers.has(memberId)) {
|
||||
uniqueMembers.set(memberId, member);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueMembers.values()).map((member) => {
|
||||
const emailSnapshot = getPrimaryEmail(member);
|
||||
return {
|
||||
clubId,
|
||||
memberId: Number(member.id),
|
||||
recipientName: getMemberDisplayName(member),
|
||||
emailSnapshot: emailSnapshot || null,
|
||||
deliveryStatus: emailSnapshot ? 'pending' : 'failed',
|
||||
deliveredAt: null,
|
||||
lastAttemptAt: null,
|
||||
attemptCount: 0,
|
||||
retryable: false,
|
||||
errorCode: emailSnapshot ? null : 'MISSING_EMAIL',
|
||||
transportMessageId: null,
|
||||
errorMessage: emailSnapshot ? null : 'Keine E-Mail-Adresse hinterlegt.',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function replaceRecipientsForThread(thread, threadPayload, transaction) {
|
||||
const recipients = await buildRecipientRowsForThread(thread.clubId, threadPayload);
|
||||
await ClubCommunicationDeliveryLog.destroy({
|
||||
where: { threadId: thread.id, clubId: thread.clubId },
|
||||
transaction,
|
||||
});
|
||||
await ClubCommunicationRecipient.destroy({
|
||||
where: { threadId: thread.id, clubId: thread.clubId },
|
||||
transaction,
|
||||
});
|
||||
if (recipients.length > 0) {
|
||||
await ClubCommunicationRecipient.bulkCreate(
|
||||
recipients.map((recipient) => ({
|
||||
...recipient,
|
||||
threadId: thread.id,
|
||||
})),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ClubCommunicationService {
|
||||
async listClubCommunication(clubId) {
|
||||
const [threads, groups, members, templates] = await Promise.all([
|
||||
ClubCommunicationThread.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{ model: ClubCommunicationMessage, as: 'messages', required: false, include: [{ model: User, as: 'createdByUser', required: false }] },
|
||||
{ model: ClubCommunicationRecipient, as: 'recipients', required: false, include: [{ model: Member, as: 'member', required: false }] },
|
||||
{
|
||||
model: ClubCommunicationDeliveryLog,
|
||||
as: 'deliveryLogs',
|
||||
required: false,
|
||||
include: [
|
||||
{ model: ClubCommunicationRecipient, as: 'recipient', required: false },
|
||||
{ model: User, as: 'createdByUser', required: false },
|
||||
],
|
||||
},
|
||||
{ model: ClubDistributionGroup, as: 'distributionGroup', required: false },
|
||||
{ model: Member, as: 'recipientMember', required: false },
|
||||
{ model: User, as: 'createdByUser', required: false },
|
||||
],
|
||||
order: [
|
||||
['updatedAt', 'DESC'],
|
||||
[{ model: ClubCommunicationMessage, as: 'messages' }, 'createdAt', 'ASC'],
|
||||
[{ model: ClubCommunicationRecipient, as: 'recipients' }, 'recipientName', 'ASC'],
|
||||
[{ model: ClubCommunicationDeliveryLog, as: 'deliveryLogs' }, 'createdAt', 'DESC'],
|
||||
],
|
||||
}),
|
||||
ClubDistributionGroup.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{
|
||||
model: ClubDistributionGroupMember,
|
||||
as: 'memberships',
|
||||
required: false,
|
||||
include: [{ model: Member, as: 'member', required: false }],
|
||||
},
|
||||
],
|
||||
order: [['isSystemGroup', 'DESC'], ['name', 'ASC']],
|
||||
}),
|
||||
Member.findAll({
|
||||
where: { clubId, active: true },
|
||||
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
|
||||
}),
|
||||
ClubCommunicationTemplate.findAll({
|
||||
where: { clubId },
|
||||
order: [['sortOrder', 'ASC'], ['name', 'ASC']],
|
||||
}),
|
||||
]);
|
||||
|
||||
return { threads, groups, members, templates };
|
||||
}
|
||||
|
||||
async createThread(clubId, userId, payload) {
|
||||
const normalized = normalizeThreadPayload(payload);
|
||||
ensureThreadTargets(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.recipientMemberId);
|
||||
await ensureGroupBelongsToClub(clubId, normalized.distributionGroupId);
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
const thread = await ClubCommunicationThread.create({
|
||||
clubId,
|
||||
createdByUserId: userId || null,
|
||||
...normalized,
|
||||
}, { transaction });
|
||||
await replaceRecipientsForThread(thread, normalized, transaction);
|
||||
return thread;
|
||||
});
|
||||
}
|
||||
|
||||
async updateThread(clubId, threadId, payload) {
|
||||
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeThreadPayload(payload);
|
||||
ensureThreadTargets(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.recipientMemberId);
|
||||
await ensureGroupBelongsToClub(clubId, normalized.distributionGroupId);
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await thread.update({
|
||||
...normalized,
|
||||
sentAt: normalized.status === 'sent' ? (thread.sentAt || new Date()) : (normalized.status === 'archived' ? thread.sentAt : null),
|
||||
}, { transaction });
|
||||
await replaceRecipientsForThread(thread, normalized, transaction);
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
|
||||
async addMessage(clubId, threadId, userId, payload) {
|
||||
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeMessagePayload(payload);
|
||||
if (!normalized.body) {
|
||||
const error = new Error('Nachrichtentext ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = await ClubCommunicationMessage.create({
|
||||
threadId: thread.id,
|
||||
clubId,
|
||||
createdByUserId: userId || null,
|
||||
...normalized,
|
||||
});
|
||||
|
||||
await thread.update({
|
||||
status: thread.status === 'archived' ? 'archived' : thread.status,
|
||||
sentAt: normalized.direction === 'outbound' && normalized.messageType === 'message' ? (thread.sentAt || new Date()) : thread.sentAt,
|
||||
});
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
async sendThread(clubId, threadId, userId = null) {
|
||||
const thread = await ClubCommunicationThread.findOne({
|
||||
where: { id: threadId, clubId },
|
||||
include: [
|
||||
{
|
||||
model: ClubCommunicationMessage,
|
||||
as: 'messages',
|
||||
required: false,
|
||||
order: [['createdAt', 'ASC']],
|
||||
},
|
||||
{
|
||||
model: ClubCommunicationRecipient,
|
||||
as: 'recipients',
|
||||
required: false,
|
||||
order: [['recipientName', 'ASC']],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const latestMessage = getLatestOutboundMessage(thread.messages || []);
|
||||
if (!latestMessage?.body) {
|
||||
const error = new Error('Zum Versand wird mindestens eine ausgehende Nachricht benötigt.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recipients = Array.isArray(thread.recipients) ? thread.recipients : [];
|
||||
let attemptedAny = false;
|
||||
let processedAny = false;
|
||||
|
||||
if (recipients.length === 0) {
|
||||
const error = new Error('Für diesen Kommunikationsvorgang sind keine Empfänger vorhanden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const eligibility = canAttemptDelivery(recipient);
|
||||
if (!eligibility.allowed) {
|
||||
if (!recipient.attemptCount && eligibility.code === 'MISSING_EMAIL') {
|
||||
const now = new Date();
|
||||
processedAny = true;
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await recipient.update({
|
||||
deliveryStatus: 'failed',
|
||||
lastAttemptAt: now,
|
||||
attemptCount: Number(recipient.attemptCount || 0) + 1,
|
||||
retryable: false,
|
||||
errorCode: eligibility.code,
|
||||
errorMessage: eligibility.reason,
|
||||
deliveredAt: null,
|
||||
transportMessageId: null,
|
||||
}, { transaction });
|
||||
|
||||
await ClubCommunicationDeliveryLog.create({
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
createdByUserId: userId || null,
|
||||
status: 'failed',
|
||||
attemptNo: Number(recipient.attemptCount || 0) + 1,
|
||||
retryable: false,
|
||||
errorCode: eligibility.code,
|
||||
errorMessage: eligibility.reason,
|
||||
}, { transaction });
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
attemptedAny = true;
|
||||
processedAny = true;
|
||||
const nextAttemptNo = Number(recipient.attemptCount || 0) + 1;
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const result = await sendClubCommunicationEmail({
|
||||
to: recipient.emailSnapshot,
|
||||
subject: thread.subject,
|
||||
text: renderMessageText(latestMessage.body),
|
||||
html: renderMessageHtml(latestMessage.body),
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await recipient.update({
|
||||
deliveryStatus: 'sent',
|
||||
deliveredAt: now,
|
||||
lastAttemptAt: now,
|
||||
attemptCount: nextAttemptNo,
|
||||
retryable: false,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
transportMessageId: trimText(result?.messageId, 255),
|
||||
}, { transaction });
|
||||
|
||||
await ClubCommunicationDeliveryLog.create({
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
createdByUserId: userId || null,
|
||||
status: 'sent',
|
||||
attemptNo: nextAttemptNo,
|
||||
retryable: false,
|
||||
transportMessageId: trimText(result?.messageId, 255),
|
||||
transportResponse: trimText(result?.response, 1000),
|
||||
}, { transaction });
|
||||
});
|
||||
} catch (sendError) {
|
||||
const classified = classifyDeliveryError(sendError);
|
||||
console.error('[sendClubCommunicationThread] delivery failed', {
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
email: recipient.emailSnapshot,
|
||||
code: classified.code,
|
||||
retryable: classified.retryable,
|
||||
message: classified.message,
|
||||
});
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await recipient.update({
|
||||
deliveryStatus: 'failed',
|
||||
deliveredAt: null,
|
||||
lastAttemptAt: now,
|
||||
attemptCount: nextAttemptNo,
|
||||
retryable: classified.retryable,
|
||||
errorCode: classified.code,
|
||||
errorMessage: classified.message,
|
||||
transportMessageId: null,
|
||||
}, { transaction });
|
||||
|
||||
await ClubCommunicationDeliveryLog.create({
|
||||
clubId,
|
||||
threadId: thread.id,
|
||||
recipientId: recipient.id,
|
||||
createdByUserId: userId || null,
|
||||
status: 'failed',
|
||||
attemptNo: nextAttemptNo,
|
||||
retryable: classified.retryable,
|
||||
errorCode: classified.code,
|
||||
errorMessage: classified.message,
|
||||
transportResponse: trimText(sendError?.response, 1000),
|
||||
}, { transaction });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!processedAny) {
|
||||
const error = new Error('Es gibt aktuell keine erneut versendbaren Empfänger.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await thread.update({
|
||||
status: 'sent',
|
||||
sentAt: thread.sentAt || new Date(),
|
||||
});
|
||||
return thread;
|
||||
}
|
||||
|
||||
async deleteThread(clubId, threadId) {
|
||||
const thread = await ClubCommunicationThread.findOne({ where: { id: threadId, clubId } });
|
||||
if (!thread) {
|
||||
const error = new Error('Kommunikationsvorgang wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await ClubCommunicationMessage.destroy({
|
||||
where: { threadId: thread.id, clubId },
|
||||
transaction,
|
||||
});
|
||||
await ClubCommunicationRecipient.destroy({
|
||||
where: { threadId: thread.id, clubId },
|
||||
transaction,
|
||||
});
|
||||
await ClubCommunicationDeliveryLog.destroy({
|
||||
where: { threadId: thread.id, clubId },
|
||||
transaction,
|
||||
});
|
||||
await thread.destroy({ transaction });
|
||||
});
|
||||
}
|
||||
|
||||
async createGroup(clubId, payload) {
|
||||
const normalized = normalizeGroupPayload(payload);
|
||||
if (!normalized.name) {
|
||||
const error = new Error('Gruppenname ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
const group = await ClubDistributionGroup.create({
|
||||
clubId,
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
groupType: normalized.groupType,
|
||||
filterDefinition: normalized.filterDefinition,
|
||||
}, { transaction });
|
||||
|
||||
if (normalized.memberIds.length > 0) {
|
||||
await ClubDistributionGroupMember.bulkCreate(
|
||||
normalized.memberIds.map((memberId) => ({ groupId: group.id, memberId })),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
|
||||
return group;
|
||||
});
|
||||
}
|
||||
|
||||
async updateGroup(clubId, groupId, payload) {
|
||||
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
|
||||
if (!group) {
|
||||
const error = new Error('Verteilergruppe wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeGroupPayload(payload);
|
||||
if (!normalized.name) {
|
||||
const error = new Error('Gruppenname ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await group.update({
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
groupType: normalized.groupType,
|
||||
filterDefinition: normalized.filterDefinition,
|
||||
}, { transaction });
|
||||
|
||||
await ClubDistributionGroupMember.destroy({
|
||||
where: { groupId: group.id },
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (normalized.memberIds.length > 0) {
|
||||
await ClubDistributionGroupMember.bulkCreate(
|
||||
normalized.memberIds.map((memberId) => ({ groupId: group.id, memberId })),
|
||||
{ transaction }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
async deleteGroup(clubId, groupId) {
|
||||
const group = await ClubDistributionGroup.findOne({ where: { id: groupId, clubId } });
|
||||
if (!group) {
|
||||
const error = new Error('Verteilergruppe wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const linkedThreads = await ClubCommunicationThread.count({
|
||||
where: { clubId, distributionGroupId: group.id },
|
||||
});
|
||||
if (linkedThreads > 0) {
|
||||
const error = new Error('Verteilergruppe kann nicht gelöscht werden, weil noch Kommunikationsvorgänge darauf verweisen.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await ClubDistributionGroupMember.destroy({ where: { groupId: group.id }, transaction });
|
||||
await group.destroy({ transaction });
|
||||
});
|
||||
}
|
||||
|
||||
async createTemplate(clubId, payload) {
|
||||
const normalized = normalizeTemplatePayload(payload);
|
||||
if (!normalized.name || !normalized.bodyTemplate) {
|
||||
const error = new Error('Vorlagenname und Nachrichtentext sind erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return ClubCommunicationTemplate.create({
|
||||
clubId,
|
||||
...normalized,
|
||||
});
|
||||
}
|
||||
|
||||
async updateTemplate(clubId, templateId, payload) {
|
||||
const template = await ClubCommunicationTemplate.findOne({
|
||||
where: { id: templateId, clubId },
|
||||
});
|
||||
if (!template) {
|
||||
const error = new Error('Vorlage wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (template.isSystemTemplate) {
|
||||
const error = new Error('Systemvorlagen können nicht bearbeitet werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeTemplatePayload(payload);
|
||||
if (!normalized.name || !normalized.bodyTemplate) {
|
||||
const error = new Error('Vorlagenname und Nachrichtentext sind erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await template.update(normalized);
|
||||
return template;
|
||||
}
|
||||
|
||||
async deleteTemplate(clubId, templateId) {
|
||||
const template = await ClubCommunicationTemplate.findOne({
|
||||
where: { id: templateId, clubId },
|
||||
});
|
||||
if (!template) {
|
||||
const error = new Error('Vorlage wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (template.isSystemTemplate) {
|
||||
const error = new Error('Systemvorlagen können nicht gelöscht werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await template.destroy();
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubCommunicationService();
|
||||
361
backend/services/clubDocumentService.js
Normal file
361
backend/services/clubDocumentService.js
Normal file
@@ -0,0 +1,361 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import sequelize from '../database.js';
|
||||
import Club from '../models/Club.js';
|
||||
import ClubDocument from '../models/ClubDocument.js';
|
||||
import ClubDocumentVersion from '../models/ClubDocumentVersion.js';
|
||||
import ClubDocumentLink from '../models/ClubDocumentLink.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DOCUMENT_TYPES = new Set(['satzung', 'protokoll', 'nachweis', 'formular', 'vertrag', 'rechnung', 'other']);
|
||||
const DOCUMENT_STATUSES = new Set(['active', 'draft', 'archived', 'obsolete']);
|
||||
const VISIBILITY_SCOPES = new Set(['board', 'finance', 'trainers', 'all']);
|
||||
|
||||
function trimText(value, maxLength = null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return null;
|
||||
return maxLength ? normalized.slice(0, maxLength) : normalized;
|
||||
}
|
||||
|
||||
function normalizePayload(payload = {}) {
|
||||
return {
|
||||
documentType: DOCUMENT_TYPES.has(payload.documentType) ? payload.documentType : 'other',
|
||||
title: trimText(payload.title, 255),
|
||||
description: trimText(payload.description),
|
||||
status: DOCUMENT_STATUSES.has(payload.status) ? payload.status : 'active',
|
||||
visibilityScope: VISIBILITY_SCOPES.has(payload.visibilityScope) ? payload.visibilityScope : 'board',
|
||||
changeNote: trimText(payload.changeNote),
|
||||
linkedEntityType: trimText(payload.linkedEntityType, 32),
|
||||
linkedEntityId: payload.linkedEntityId ? Number(payload.linkedEntityId) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileName(value = '') {
|
||||
return String(value || 'document').replace(/[^a-zA-Z0-9._-]+/g, '_');
|
||||
}
|
||||
|
||||
function getStorageDir(clubId) {
|
||||
return path.join(__dirname, '..', 'uploads', 'club-documents', String(clubId));
|
||||
}
|
||||
|
||||
function ensureStorageDir(clubId) {
|
||||
const uploadDir = getStorageDir(clubId);
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
return uploadDir;
|
||||
}
|
||||
|
||||
function checksumFile(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
hash.update(fileBuffer);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function moveUploadedFile(tempPath, destinationPath) {
|
||||
try {
|
||||
fs.renameSync(tempPath, destinationPath);
|
||||
} catch (error) {
|
||||
if (error.code === 'EXDEV') {
|
||||
fs.copyFileSync(tempPath, destinationPath);
|
||||
fs.unlinkSync(tempPath);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDocumentSnapshot(document, versions = [], links = []) {
|
||||
const orderedVersions = [...versions].sort((a, b) => Number(b.versionNo || 0) - Number(a.versionNo || 0));
|
||||
const latestVersion = orderedVersions[0] || null;
|
||||
return {
|
||||
...document.get({ plain: true }),
|
||||
versionCount: versions.length,
|
||||
latestVersion: latestVersion ? latestVersion.get({ plain: true }) : null,
|
||||
versions: orderedVersions.map((version) => version.get({ plain: true })),
|
||||
links: links.map((link) => link.get({ plain: true })),
|
||||
linkedEntityCount: links.length,
|
||||
};
|
||||
}
|
||||
|
||||
class ClubDocumentService {
|
||||
async listClubDocuments(clubId, filters = {}) {
|
||||
const [club, documents] = await Promise.all([
|
||||
Club.findByPk(clubId, { attributes: ['id'] }),
|
||||
ClubDocument.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{ model: ClubDocumentVersion, as: 'versions', required: false },
|
||||
{ model: ClubDocumentLink, as: 'links', required: false },
|
||||
],
|
||||
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!club) {
|
||||
const error = new Error('Verein wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const search = trimText(filters.search)?.toLowerCase() || '';
|
||||
const typeFilter = trimText(filters.documentType);
|
||||
const statusFilter = trimText(filters.status);
|
||||
const visibilityFilter = trimText(filters.visibilityScope);
|
||||
|
||||
return documents
|
||||
.filter((document) => {
|
||||
if (typeFilter && document.documentType !== typeFilter) return false;
|
||||
if (statusFilter && document.status !== statusFilter) return false;
|
||||
if (visibilityFilter && document.visibilityScope !== visibilityFilter) return false;
|
||||
if (!search) return true;
|
||||
|
||||
const haystack = [
|
||||
document.title,
|
||||
document.description,
|
||||
document.documentType,
|
||||
document.status,
|
||||
document.visibilityScope,
|
||||
...(document.versions || []).map((version) => version.fileName),
|
||||
...(document.links || []).map((link) => `${link.linkedEntityType} ${link.linkedEntityId}`),
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
|
||||
return haystack.includes(search);
|
||||
})
|
||||
.map((document) => buildDocumentSnapshot(document, document.versions || [], document.links || []));
|
||||
}
|
||||
|
||||
async createClubDocument(clubId, userId, payload = {}, file = null) {
|
||||
const normalized = normalizePayload(payload);
|
||||
if (!normalized.title) {
|
||||
const error = new Error('Bitte einen Dokumenttitel angeben.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!file) {
|
||||
const error = new Error('Eine Datei ist für das neue Dokument erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const club = await Club.findByPk(clubId, { attributes: ['id'] });
|
||||
if (!club) {
|
||||
const error = new Error('Verein wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const uploadDir = ensureStorageDir(clubId);
|
||||
const safeFileName = normalizeFileName(file.originalname);
|
||||
const storageFileName = `${Date.now()}_${safeFileName}`;
|
||||
const storagePath = path.join(uploadDir, storageFileName);
|
||||
moveUploadedFile(file.path, storagePath);
|
||||
|
||||
const checksum = checksumFile(storagePath);
|
||||
const transaction = await sequelize.transaction();
|
||||
try {
|
||||
const document = await ClubDocument.create({
|
||||
clubId,
|
||||
documentType: normalized.documentType,
|
||||
title: normalized.title,
|
||||
description: normalized.description,
|
||||
status: normalized.status,
|
||||
visibilityScope: normalized.visibilityScope,
|
||||
ownerUserId: userId || null,
|
||||
currentVersionNo: 1,
|
||||
}, { transaction });
|
||||
|
||||
const version = await ClubDocumentVersion.create({
|
||||
documentId: document.id,
|
||||
versionNo: 1,
|
||||
fileName: safeFileName,
|
||||
storagePath,
|
||||
mimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
checksumSha256: checksum,
|
||||
uploadedByUserId: userId || null,
|
||||
changeNote: normalized.changeNote,
|
||||
}, { transaction });
|
||||
|
||||
if (normalized.linkedEntityType && normalized.linkedEntityId) {
|
||||
await ClubDocumentLink.create({
|
||||
documentId: document.id,
|
||||
linkedEntityType: normalized.linkedEntityType,
|
||||
linkedEntityId: normalized.linkedEntityId,
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return this.getClubDocumentById(clubId, document.id);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
if (fs.existsSync(storagePath)) {
|
||||
fs.unlinkSync(storagePath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateClubDocument(clubId, documentId, userId, payload = {}, file = null) {
|
||||
const normalized = normalizePayload(payload);
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [
|
||||
{ model: ClubDocumentVersion, as: 'versions', required: false },
|
||||
{ model: ClubDocumentLink, as: 'links', required: false },
|
||||
],
|
||||
});
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const transaction = await sequelize.transaction();
|
||||
let uploadedStoragePath = null;
|
||||
try {
|
||||
await document.update({
|
||||
title: normalized.title || document.title,
|
||||
description: normalized.description,
|
||||
status: normalized.status,
|
||||
visibilityScope: normalized.visibilityScope,
|
||||
documentType: normalized.documentType,
|
||||
}, { transaction });
|
||||
|
||||
if (file) {
|
||||
const uploadDir = ensureStorageDir(clubId);
|
||||
const nextVersionNo = Number(document.currentVersionNo || 1) + 1;
|
||||
const safeFileName = normalizeFileName(file.originalname);
|
||||
const storageFileName = `${Date.now()}_v${nextVersionNo}_${safeFileName}`;
|
||||
const storagePath = path.join(uploadDir, storageFileName);
|
||||
moveUploadedFile(file.path, storagePath);
|
||||
uploadedStoragePath = storagePath;
|
||||
const checksum = checksumFile(storagePath);
|
||||
|
||||
await ClubDocumentVersion.create({
|
||||
documentId: document.id,
|
||||
versionNo: nextVersionNo,
|
||||
fileName: safeFileName,
|
||||
storagePath,
|
||||
mimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
checksumSha256: checksum,
|
||||
uploadedByUserId: userId || null,
|
||||
changeNote: normalized.changeNote,
|
||||
}, { transaction });
|
||||
|
||||
await document.update({
|
||||
currentVersionNo: nextVersionNo,
|
||||
archivedAt: normalized.status === 'archived' ? new Date() : null,
|
||||
status: normalized.status,
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
if (normalized.linkedEntityType && normalized.linkedEntityId) {
|
||||
await ClubDocumentLink.findOrCreate({
|
||||
where: {
|
||||
documentId: document.id,
|
||||
linkedEntityType: normalized.linkedEntityType,
|
||||
linkedEntityId: normalized.linkedEntityId,
|
||||
},
|
||||
defaults: {
|
||||
documentId: document.id,
|
||||
linkedEntityType: normalized.linkedEntityType,
|
||||
linkedEntityId: normalized.linkedEntityId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return this.getClubDocumentById(clubId, document.id);
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
if (uploadedStoragePath && fs.existsSync(uploadedStoragePath)) {
|
||||
fs.unlinkSync(uploadedStoragePath);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async archiveClubDocument(clubId, documentId) {
|
||||
const document = await ClubDocument.findOne({ where: { id: documentId, clubId } });
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await document.update({
|
||||
status: 'archived',
|
||||
archivedAt: new Date(),
|
||||
});
|
||||
return this.getClubDocumentById(clubId, document.id);
|
||||
}
|
||||
|
||||
async deleteClubDocument(clubId, documentId) {
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [{ model: ClubDocumentVersion, as: 'versions', required: false }],
|
||||
});
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const version of document.versions || []) {
|
||||
if (version.storagePath && fs.existsSync(version.storagePath)) {
|
||||
fs.unlinkSync(version.storagePath);
|
||||
}
|
||||
}
|
||||
await ClubDocumentLink.destroy({ where: { documentId } });
|
||||
await ClubDocumentVersion.destroy({ where: { documentId } });
|
||||
await document.destroy();
|
||||
return true;
|
||||
}
|
||||
|
||||
async getClubDocumentById(clubId, documentId) {
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [
|
||||
{ model: ClubDocumentVersion, as: 'versions', required: false },
|
||||
{ model: ClubDocumentLink, as: 'links', required: false },
|
||||
],
|
||||
});
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
return buildDocumentSnapshot(document, document.versions || [], document.links || []);
|
||||
}
|
||||
|
||||
async getDocumentDownload(clubId, documentId, versionNo = null) {
|
||||
const document = await ClubDocument.findOne({
|
||||
where: { id: documentId, clubId },
|
||||
include: [{ model: ClubDocumentVersion, as: 'versions', required: false }],
|
||||
});
|
||||
if (!document) {
|
||||
const error = new Error('Dokument wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const versions = document.versions || [];
|
||||
const selectedVersion = versionNo
|
||||
? versions.find((version) => Number(version.versionNo) === Number(versionNo))
|
||||
: [...versions].sort((a, b) => Number(b.versionNo || 0) - Number(a.versionNo || 0))[0];
|
||||
|
||||
if (!selectedVersion) {
|
||||
const error = new Error('Dokumentversion wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { document: document.get({ plain: true }), version: selectedVersion.get({ plain: true }) };
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubDocumentService();
|
||||
482
backend/services/clubInvoiceService.js
Normal file
482
backend/services/clubInvoiceService.js
Normal file
@@ -0,0 +1,482 @@
|
||||
import sequelize from '../database.js';
|
||||
import Club from '../models/Club.js';
|
||||
import ClubAccountTransaction from '../models/ClubAccountTransaction.js';
|
||||
import ClubInvoice from '../models/ClubInvoice.js';
|
||||
import ClubInvoiceItem from '../models/ClubInvoiceItem.js';
|
||||
import ClubInvoiceParty from '../models/ClubInvoiceParty.js';
|
||||
import ClubAccount from '../models/ClubAccount.js';
|
||||
|
||||
const INVOICE_DIRECTIONS = new Set(['incoming', 'outgoing']);
|
||||
const INVOICE_STATUSES = new Set(['draft', 'issued', 'partially_paid', 'paid', 'cancelled', 'archived']);
|
||||
const PARTY_TYPES = new Set(['customer', 'supplier', 'sponsor', 'other']);
|
||||
const INVOICE_TYPES = new Set(['membership_fee', 'course_fee', 'sponsoring', 'material', 'service', 'expense', 'other']);
|
||||
|
||||
function trimText(value, maxLength = null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return null;
|
||||
return maxLength ? normalized.slice(0, maxLength) : normalized;
|
||||
}
|
||||
|
||||
function normalizeDate(value) {
|
||||
const normalized = String(value || '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function parseNumber(value, fallback = 0) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : fallback;
|
||||
}
|
||||
|
||||
function roundToInt(value) {
|
||||
return Math.round(Number(value) || 0);
|
||||
}
|
||||
|
||||
function normalizePartyPayload(payload = {}) {
|
||||
return {
|
||||
name: trimText(payload.name, 255),
|
||||
partyType: PARTY_TYPES.has(payload.partyType) ? payload.partyType : 'customer',
|
||||
status: trimText(payload.status, 32) || 'active',
|
||||
contractReference: trimText(payload.contractReference, 120),
|
||||
validFrom: normalizeDate(payload.validFrom),
|
||||
validTo: normalizeDate(payload.validTo),
|
||||
contactName: trimText(payload.contactName, 255),
|
||||
email: trimText(payload.email, 255),
|
||||
phone: trimText(payload.phone, 80),
|
||||
street: trimText(payload.street, 255),
|
||||
postalCode: trimText(payload.postalCode, 24),
|
||||
city: trimText(payload.city, 120),
|
||||
countryCode: trimText(payload.countryCode, 2)?.toUpperCase() || 'DE',
|
||||
iban: trimText(payload.iban, 34)?.replace(/\s+/g, '').toUpperCase() || null,
|
||||
bic: trimText(payload.bic, 11)?.replace(/\s+/g, '').toUpperCase() || null,
|
||||
taxIdentifier: trimText(payload.taxIdentifier, 64),
|
||||
notes: trimText(payload.notes),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInvoiceItems(items = []) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.map((item, index) => {
|
||||
const quantity = parseNumber(item.quantity, 1);
|
||||
const unitPriceCents = roundToInt(item.unitPriceCents);
|
||||
const taxRate = parseNumber(item.taxRate, 0);
|
||||
const netLineCents = roundToInt(quantity * unitPriceCents);
|
||||
const taxLineCents = roundToInt(netLineCents * (taxRate / 100));
|
||||
const totalCents = netLineCents + taxLineCents;
|
||||
return {
|
||||
lineNo: index + 1,
|
||||
description: trimText(item.description) || '',
|
||||
quantity,
|
||||
unitPriceCents,
|
||||
taxRate,
|
||||
netLineCents,
|
||||
taxLineCents,
|
||||
totalCents,
|
||||
};
|
||||
})
|
||||
.filter((item) => item.description);
|
||||
}
|
||||
|
||||
function normalizeInvoicePayload(payload = {}) {
|
||||
return {
|
||||
invoiceDirection: INVOICE_DIRECTIONS.has(payload.invoiceDirection) ? payload.invoiceDirection : 'outgoing',
|
||||
invoiceType: INVOICE_TYPES.has(payload.invoiceType) ? payload.invoiceType : 'other',
|
||||
status: INVOICE_STATUSES.has(payload.status) ? payload.status : 'draft',
|
||||
externalReference: trimText(payload.externalReference, 255),
|
||||
partyId: payload.partyId ? Number(payload.partyId) : null,
|
||||
accountId: payload.accountId ? Number(payload.accountId) : null,
|
||||
issuedOn: normalizeDate(payload.issuedOn),
|
||||
dueOn: normalizeDate(payload.dueOn),
|
||||
paidOn: normalizeDate(payload.paidOn),
|
||||
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
|
||||
description: trimText(payload.description),
|
||||
items: normalizeInvoiceItems(payload.items),
|
||||
};
|
||||
}
|
||||
|
||||
function validateInvoicePayload(payload) {
|
||||
if (!payload.partyId) {
|
||||
const error = new Error('Bitte eine Rechnungspartei auswählen.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (payload.items.length === 0) {
|
||||
const error = new Error('Mindestens eine Rechnungsposition ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeItems(items) {
|
||||
return items.reduce((acc, item) => {
|
||||
acc.netAmountCents += item.netLineCents;
|
||||
acc.taxAmountCents += item.taxLineCents;
|
||||
acc.grossAmountCents += item.totalCents;
|
||||
return acc;
|
||||
}, {
|
||||
netAmountCents: 0,
|
||||
taxAmountCents: 0,
|
||||
grossAmountCents: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function buildInvoiceNumber(prefix, nextNumber, referenceDate = new Date()) {
|
||||
const parsedReferenceDate = referenceDate instanceof Date ? referenceDate : new Date(referenceDate);
|
||||
const year = Number.isNaN(parsedReferenceDate.getTime()) ? new Date().getFullYear() : parsedReferenceDate.getFullYear();
|
||||
const normalizedPrefix = String(prefix || '').trim().toUpperCase();
|
||||
const paddedCounter = String(Math.max(1, Number.parseInt(nextNumber, 10) || 1)).padStart(4, '0');
|
||||
return normalizedPrefix ? `${normalizedPrefix}-${year}-${paddedCounter}` : `${year}-${paddedCounter}`;
|
||||
}
|
||||
|
||||
function deriveInvoiceTransactionData(invoice) {
|
||||
if (!invoice?.accountId || invoice.status === 'draft') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const amountCents = Math.max(0, Number.parseInt(invoice.grossAmountCents, 10) || 0);
|
||||
if (!amountCents) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bookingDate = invoice.paidOn || invoice.issuedOn || invoice.dueOn || new Date().toISOString().slice(0, 10);
|
||||
const status = invoice.status === 'paid'
|
||||
? 'booked'
|
||||
: ['issued', 'partially_paid'].includes(invoice.status)
|
||||
? 'planned'
|
||||
: 'cancelled';
|
||||
|
||||
return {
|
||||
clubId: invoice.clubId,
|
||||
accountId: invoice.accountId,
|
||||
invoiceId: invoice.id,
|
||||
direction: invoice.invoiceDirection === 'incoming' ? 'debit' : 'credit',
|
||||
bookingType: 'invoice',
|
||||
status,
|
||||
bookingDate,
|
||||
valueDate: bookingDate,
|
||||
amountCents,
|
||||
currencyCode: invoice.currencyCode || 'EUR',
|
||||
reference: invoice.invoiceNumber || invoice.externalReference || `Rechnung ${invoice.id}`,
|
||||
notes: invoice.description || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function syncInvoiceAccountTransaction(invoice, transaction) {
|
||||
const transactionData = deriveInvoiceTransactionData(invoice);
|
||||
const existingTransaction = await ClubAccountTransaction.findOne({
|
||||
where: {
|
||||
clubId: invoice.clubId,
|
||||
invoiceId: invoice.id,
|
||||
bookingType: 'invoice',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!transactionData) {
|
||||
if (existingTransaction) {
|
||||
await existingTransaction.destroy({ transaction });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existingTransaction) {
|
||||
await existingTransaction.update(transactionData, { transaction });
|
||||
return existingTransaction;
|
||||
}
|
||||
|
||||
return ClubAccountTransaction.create(transactionData, { transaction });
|
||||
}
|
||||
|
||||
async function generateNextInvoiceNumber(clubId, invoiceDirection, issuedOn, transaction) {
|
||||
const club = await Club.findByPk(clubId, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
|
||||
if (!club) {
|
||||
const error = new Error('Verein wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isIncoming = invoiceDirection === 'incoming';
|
||||
const prefixField = isIncoming ? 'incomingInvoicePrefix' : 'outgoingInvoicePrefix';
|
||||
const nextNumberField = isIncoming ? 'incomingInvoiceNextNumber' : 'outgoingInvoiceNextNumber';
|
||||
const referenceDate = issuedOn || new Date();
|
||||
let nextNumber = Math.max(1, Number.parseInt(club[nextNumberField], 10) || 1);
|
||||
let invoiceNumber = buildInvoiceNumber(club[prefixField], nextNumber, referenceDate);
|
||||
|
||||
// Falls der Nummernkreis manuell zurückgesetzt wurde, wird bis zur nächsten freien Nummer weitergezählt.
|
||||
while (await ClubInvoice.count({
|
||||
where: { clubId, invoiceNumber },
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
})) {
|
||||
nextNumber += 1;
|
||||
invoiceNumber = buildInvoiceNumber(club[prefixField], nextNumber, referenceDate);
|
||||
}
|
||||
|
||||
await club.update({
|
||||
[nextNumberField]: nextNumber + 1,
|
||||
}, { transaction });
|
||||
|
||||
return invoiceNumber;
|
||||
}
|
||||
|
||||
class ClubInvoiceService {
|
||||
async listClubInvoices(clubId) {
|
||||
const [club, parties, accounts, invoices] = await Promise.all([
|
||||
Club.findByPk(clubId, {
|
||||
attributes: [
|
||||
'id',
|
||||
'outgoingInvoicePrefix',
|
||||
'outgoingInvoiceNextNumber',
|
||||
'incomingInvoicePrefix',
|
||||
'incomingInvoiceNextNumber',
|
||||
],
|
||||
}),
|
||||
ClubInvoiceParty.findAll({
|
||||
where: { clubId },
|
||||
order: [['name', 'ASC']],
|
||||
}),
|
||||
ClubAccount.findAll({
|
||||
where: { clubId },
|
||||
order: [['isDefault', 'DESC'], ['name', 'ASC']],
|
||||
}),
|
||||
ClubInvoice.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{ model: ClubInvoiceParty, as: 'party', required: false },
|
||||
{ model: ClubInvoiceItem, as: 'items', required: false },
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
],
|
||||
order: [['updatedAt', 'DESC'], [{ model: ClubInvoiceItem, as: 'items' }, 'lineNo', 'ASC']],
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
parties,
|
||||
accounts,
|
||||
invoices,
|
||||
settings: club ? {
|
||||
outgoingInvoicePrefix: club.outgoingInvoicePrefix || 'RE',
|
||||
outgoingInvoiceNextNumber: club.outgoingInvoiceNextNumber || 1,
|
||||
incomingInvoicePrefix: club.incomingInvoicePrefix || 'EI',
|
||||
incomingInvoiceNextNumber: club.incomingInvoiceNextNumber || 1,
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
async createInvoiceParty(clubId, payload) {
|
||||
const normalized = normalizePartyPayload(payload);
|
||||
if (!normalized.name) {
|
||||
const error = new Error('Name der Rechnungspartei ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
return ClubInvoiceParty.create({ clubId, ...normalized });
|
||||
}
|
||||
|
||||
async updateInvoiceParty(clubId, partyId, payload) {
|
||||
const party = await ClubInvoiceParty.findOne({ where: { id: partyId, clubId } });
|
||||
if (!party) {
|
||||
const error = new Error('Rechnungspartei wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
const normalized = normalizePartyPayload(payload);
|
||||
if (!normalized.name) {
|
||||
const error = new Error('Name der Rechnungspartei ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
await party.update(normalized);
|
||||
return party;
|
||||
}
|
||||
|
||||
async deleteInvoiceParty(clubId, partyId) {
|
||||
const party = await ClubInvoiceParty.findOne({ where: { id: partyId, clubId } });
|
||||
if (!party) {
|
||||
const error = new Error('Rechnungspartei wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
const linkedCount = await ClubInvoice.count({ where: { clubId, partyId } });
|
||||
if (linkedCount > 0) {
|
||||
const error = new Error('Rechnungspartei kann nicht gelöscht werden, weil bereits Rechnungen darauf verweisen.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
await party.destroy();
|
||||
}
|
||||
|
||||
async createInvoice(clubId, userId, payload) {
|
||||
const normalized = normalizeInvoicePayload(payload);
|
||||
validateInvoicePayload(normalized);
|
||||
const totals = summarizeItems(normalized.items);
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
const invoiceNumber = await generateNextInvoiceNumber(
|
||||
clubId,
|
||||
normalized.invoiceDirection,
|
||||
normalized.issuedOn,
|
||||
transaction
|
||||
);
|
||||
|
||||
const invoice = await ClubInvoice.create({
|
||||
clubId,
|
||||
createdByUserId: userId || null,
|
||||
...normalized,
|
||||
invoiceNumber,
|
||||
...totals,
|
||||
archivedAt: normalized.status === 'archived' ? new Date() : null,
|
||||
}, { transaction });
|
||||
|
||||
await ClubInvoiceItem.bulkCreate(
|
||||
normalized.items.map((item) => ({
|
||||
invoiceId: invoice.id,
|
||||
lineNo: item.lineNo,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unitPriceCents: item.unitPriceCents,
|
||||
taxRate: item.taxRate,
|
||||
totalCents: item.totalCents,
|
||||
})),
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await syncInvoiceAccountTransaction(invoice, transaction);
|
||||
|
||||
return ClubInvoice.findOne({
|
||||
where: { id: invoice.id, clubId },
|
||||
include: [
|
||||
{ model: ClubInvoiceParty, as: 'party', required: false },
|
||||
{ model: ClubInvoiceItem, as: 'items', required: false },
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
],
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateInvoice(clubId, invoiceId, payload) {
|
||||
const invoice = await ClubInvoice.findOne({ where: { id: invoiceId, clubId } });
|
||||
if (!invoice) {
|
||||
const error = new Error('Rechnung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizeInvoicePayload(payload);
|
||||
validateInvoicePayload(normalized);
|
||||
const totals = summarizeItems(normalized.items);
|
||||
|
||||
return sequelize.transaction(async (transaction) => {
|
||||
if (invoice.invoiceNumber && normalized.invoiceDirection !== invoice.invoiceDirection) {
|
||||
const error = new Error('Die Rechnungsrichtung kann nach Vergabe der Rechnungsnummer nicht mehr geändert werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const invoiceNumber = invoice.invoiceNumber || await generateNextInvoiceNumber(
|
||||
clubId,
|
||||
normalized.invoiceDirection,
|
||||
normalized.issuedOn || invoice.issuedOn,
|
||||
transaction
|
||||
);
|
||||
|
||||
await invoice.update({
|
||||
...normalized,
|
||||
invoiceNumber,
|
||||
...totals,
|
||||
archivedAt: normalized.status === 'archived' ? (invoice.archivedAt || new Date()) : null,
|
||||
}, { transaction });
|
||||
|
||||
await ClubInvoiceItem.destroy({
|
||||
where: { invoiceId: invoice.id },
|
||||
transaction,
|
||||
});
|
||||
|
||||
await ClubInvoiceItem.bulkCreate(
|
||||
normalized.items.map((item) => ({
|
||||
invoiceId: invoice.id,
|
||||
lineNo: item.lineNo,
|
||||
description: item.description,
|
||||
quantity: item.quantity,
|
||||
unitPriceCents: item.unitPriceCents,
|
||||
taxRate: item.taxRate,
|
||||
totalCents: item.totalCents,
|
||||
})),
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
await syncInvoiceAccountTransaction(invoice, transaction);
|
||||
|
||||
return ClubInvoice.findOne({
|
||||
where: { id: invoice.id, clubId },
|
||||
include: [
|
||||
{ model: ClubInvoiceParty, as: 'party', required: false },
|
||||
{ model: ClubInvoiceItem, as: 'items', required: false },
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
],
|
||||
transaction,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateInvoiceStatus(clubId, invoiceId, status) {
|
||||
if (!INVOICE_STATUSES.has(status)) {
|
||||
const error = new Error('Ungültiger Rechnungsstatus.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const invoice = await ClubInvoice.findOne({ where: { id: invoiceId, clubId } });
|
||||
if (!invoice) {
|
||||
const error = new Error('Rechnung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await invoice.update({
|
||||
status,
|
||||
archivedAt: status === 'archived' ? (invoice.archivedAt || new Date()) : null,
|
||||
paidOn: status === 'paid' ? (invoice.paidOn || new Date().toISOString().slice(0, 10)) : invoice.paidOn,
|
||||
}, { transaction });
|
||||
await syncInvoiceAccountTransaction(invoice, transaction);
|
||||
});
|
||||
|
||||
return invoice.reload({
|
||||
include: [
|
||||
{ model: ClubInvoiceParty, as: 'party', required: false },
|
||||
{ model: ClubInvoiceItem, as: 'items', required: false },
|
||||
{ model: ClubAccount, as: 'account', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInvoice(clubId, invoiceId) {
|
||||
const invoice = await ClubInvoice.findOne({ where: { id: invoiceId, clubId } });
|
||||
if (!invoice) {
|
||||
const error = new Error('Rechnung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await ClubAccountTransaction.destroy({
|
||||
where: {
|
||||
clubId,
|
||||
invoiceId,
|
||||
bookingType: 'invoice',
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
await ClubInvoiceItem.destroy({ where: { invoiceId }, transaction });
|
||||
await invoice.destroy({ transaction });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubInvoiceService();
|
||||
22
backend/services/clubPaymentClaimCompatibility.js
Normal file
22
backend/services/clubPaymentClaimCompatibility.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import sequelize from '../database.js';
|
||||
|
||||
let hasPaidAmountCentsColumnPromise = null;
|
||||
|
||||
function isMissingTableError(error) {
|
||||
return error?.original?.code === 'ER_NO_SUCH_TABLE';
|
||||
}
|
||||
|
||||
export async function hasClubPaymentClaimPaidAmountCentsColumn() {
|
||||
if (!hasPaidAmountCentsColumnPromise) {
|
||||
hasPaidAmountCentsColumnPromise = sequelize.getQueryInterface().describeTable('club_payment_claims')
|
||||
.then((description) => Boolean(description?.paid_amount_cents))
|
||||
.catch((error) => {
|
||||
if (isMissingTableError(error)) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return hasPaidAmountCentsColumnPromise;
|
||||
}
|
||||
414
backend/services/clubPaymentClaimService.js
Normal file
414
backend/services/clubPaymentClaimService.js
Normal file
@@ -0,0 +1,414 @@
|
||||
import { Op, Transaction } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import { ClubAccount, ClubAccountTransaction, ClubPaymentClaim, Member } from '../models/index.js';
|
||||
import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js';
|
||||
|
||||
const CLAIM_TYPES = new Set(['membership_fee', 'additional_fee', 'course_fee', 'penalty_fee', 'other']);
|
||||
const CLAIM_STATUSES = new Set(['open', 'partially_paid', 'paid', 'written_off', 'cancelled']);
|
||||
|
||||
function trimText(value, maxLength = null) {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return null;
|
||||
return maxLength ? normalized.slice(0, maxLength) : normalized;
|
||||
}
|
||||
|
||||
function normalizePayload(payload = {}) {
|
||||
return {
|
||||
memberId: Number(payload.memberId) || null,
|
||||
feeRuleId: Number(payload.feeRuleId) || null,
|
||||
claimType: CLAIM_TYPES.has(payload.claimType) ? payload.claimType : 'membership_fee',
|
||||
status: CLAIM_STATUSES.has(payload.status) ? payload.status : 'open',
|
||||
dueOn: trimText(payload.dueOn, 10),
|
||||
amountCents: Number.parseInt(payload.amountCents, 10) || 0,
|
||||
paidAmountCents: Math.max(0, Number.parseInt(payload.paidAmountCents, 10) || 0),
|
||||
currencyCode: trimText(payload.currencyCode, 3)?.toUpperCase() || 'EUR',
|
||||
reminderLevel: Math.max(0, Number.parseInt(payload.reminderLevel, 10) || 0),
|
||||
notes: trimText(payload.notes),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReminderTimestamp(existingClaim, nextReminderLevel) {
|
||||
const previousReminderLevel = Math.max(0, Number.parseInt(existingClaim?.reminderLevel, 10) || 0);
|
||||
if (nextReminderLevel <= 0) return null;
|
||||
if (nextReminderLevel > previousReminderLevel) return new Date();
|
||||
return existingClaim?.lastReminderAt || new Date();
|
||||
}
|
||||
|
||||
function clampPaidAmount(amountCents, paidAmountCents) {
|
||||
return Math.max(0, Math.min(Number(amountCents || 0), Number(paidAmountCents || 0)));
|
||||
}
|
||||
|
||||
function deriveFinancialStatus(status, amountCents, paidAmountCents) {
|
||||
if (status === 'cancelled' || status === 'written_off') return status;
|
||||
if (Number(amountCents || 0) <= 0) return 'open';
|
||||
if (paidAmountCents >= amountCents) return 'paid';
|
||||
if (paidAmountCents > 0) return 'partially_paid';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function deriveStatusFromBalance(amountCents, paidAmountCents) {
|
||||
if (Number(amountCents || 0) <= 0) return 'open';
|
||||
if (paidAmountCents >= amountCents) return 'paid';
|
||||
if (paidAmountCents > 0) return 'partially_paid';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function validatePayload(payload) {
|
||||
if (!payload.memberId) {
|
||||
const error = new Error('Mitglied ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!payload.dueOn) {
|
||||
const error = new Error('Fälligkeitsdatum ist erforderlich.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
if (!Number.isFinite(Number(payload.amountCents)) || Number(payload.amountCents) <= 0) {
|
||||
const error = new Error('Der Betrag muss größer als 0 sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureMemberBelongsToClub(clubId, memberId) {
|
||||
const member = await Member.findOne({ where: { id: memberId, clubId } });
|
||||
if (!member) {
|
||||
const error = new Error('Mitglied wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
return member;
|
||||
}
|
||||
|
||||
class ClubPaymentClaimService {
|
||||
async listClaims(clubId) {
|
||||
if (!(await hasClubPaymentClaimPaidAmountCentsColumn())) {
|
||||
return { claims: [] };
|
||||
}
|
||||
|
||||
const claims = await ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
[Op.or]: [
|
||||
{ archivedAt: null },
|
||||
{ archivedAt: { [Op.is]: null } },
|
||||
],
|
||||
status: { [Op.notIn]: ['written_off', 'cancelled'] },
|
||||
},
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
order: [['dueOn', 'ASC'], ['updatedAt', 'DESC']],
|
||||
});
|
||||
|
||||
return { claims };
|
||||
}
|
||||
|
||||
async createClaim(clubId, payload) {
|
||||
const normalized = normalizePayload(payload);
|
||||
validatePayload(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.memberId);
|
||||
const paidAmountCents = clampPaidAmount(normalized.amountCents, normalized.paidAmountCents);
|
||||
const status = deriveFinancialStatus(normalized.status, normalized.amountCents, paidAmountCents);
|
||||
|
||||
const claim = await ClubPaymentClaim.create({
|
||||
clubId,
|
||||
...normalized,
|
||||
paidAmountCents,
|
||||
status,
|
||||
settledAt: status === 'paid' ? new Date() : null,
|
||||
lastPaidAt: paidAmountCents > 0 ? new Date() : null,
|
||||
lastReminderAt: normalized.reminderLevel > 0 ? new Date() : null,
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async updateClaim(clubId, claimId, payload) {
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const normalized = normalizePayload(payload);
|
||||
validatePayload(normalized);
|
||||
await ensureMemberBelongsToClub(clubId, normalized.memberId);
|
||||
const paidAmountCents = clampPaidAmount(normalized.amountCents, normalized.paidAmountCents);
|
||||
const status = deriveFinancialStatus(normalized.status, normalized.amountCents, paidAmountCents);
|
||||
const previousPaidAmount = Number(claim.paidAmountCents || 0);
|
||||
|
||||
await claim.update({
|
||||
...normalized,
|
||||
paidAmountCents,
|
||||
status,
|
||||
settledAt: status === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: paidAmountCents > previousPaidAmount ? new Date() : (paidAmountCents > 0 ? (claim.lastPaidAt || new Date()) : null),
|
||||
lastReminderAt: resolveReminderTimestamp(claim, normalized.reminderLevel),
|
||||
archivedAt: ['written_off', 'cancelled'].includes(status) ? (claim.archivedAt || new Date()) : null,
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async updateClaimStatus(clubId, claimId, status) {
|
||||
if (!CLAIM_STATUSES.has(status)) {
|
||||
const error = new Error('Ungültiger Zahlungsstatus.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await claim.update({
|
||||
status,
|
||||
paidAmountCents: status === 'paid' ? Number(claim.amountCents || 0) : Number(claim.paidAmountCents || 0),
|
||||
settledAt: status === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: status === 'paid' ? (claim.lastPaidAt || new Date()) : claim.lastPaidAt,
|
||||
archivedAt: ['written_off', 'cancelled'].includes(status) ? (claim.archivedAt || new Date()) : null,
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async deleteClaim(clubId, claimId) {
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await claim.destroy();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async registerPayment(clubId, claimId, payload = {}) {
|
||||
const amountCents = Number.parseInt(payload.amountCents, 10) || 0;
|
||||
if (amountCents <= 0) {
|
||||
const error = new Error('Der Zahlungsbetrag muss größer als 0 sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const claim = await ClubPaymentClaim.findOne({ where: { id: claimId, clubId } });
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
if (['cancelled', 'written_off', 'paid'].includes(claim.status)) {
|
||||
const error = new Error('Für diesen Status kann keine Teilzahlung mehr erfasst werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
const accountId = Number(payload.accountId) || null;
|
||||
let account = null;
|
||||
if (accountId) {
|
||||
account = await ClubAccount.findOne({ where: { id: accountId, clubId } });
|
||||
if (!account) {
|
||||
const error = new Error('Konto wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const nextPaidAmount = clampPaidAmount(claim.amountCents, Number(claim.paidAmountCents || 0) + amountCents);
|
||||
const actualPaymentAmountCents = nextPaidAmount - Number(claim.paidAmountCents || 0);
|
||||
if (actualPaymentAmountCents <= 0) {
|
||||
const error = new Error('Die Forderung ist bereits vollständig beglichen.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
const nextStatus = deriveFinancialStatus(claim.status, claim.amountCents, nextPaidAmount);
|
||||
const note = trimText(payload.note);
|
||||
const notePrefix = `Teilzahlung ${formatMoney(actualPaymentAmountCents, claim.currencyCode)} erfasst`;
|
||||
const appendedNotes = notePrefix
|
||||
? [claim.notes, `${notePrefix}${note ? `: ${note}` : ''}`].filter(Boolean).join('\n')
|
||||
: claim.notes;
|
||||
const bookingDate = trimText(payload.bookingDate, 10) || new Date().toISOString().slice(0, 10);
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
await this.applyPaymentToClaim(clubId, claim, {
|
||||
amountCents: actualPaymentAmountCents,
|
||||
paidAmountCents: nextPaidAmount,
|
||||
status: nextStatus,
|
||||
notes: appendedNotes,
|
||||
}, transaction);
|
||||
|
||||
if (account) {
|
||||
await ClubAccountTransaction.create({
|
||||
clubId,
|
||||
accountId: account.id,
|
||||
paymentClaimId: claim.id,
|
||||
direction: 'credit',
|
||||
bookingType: 'payment_claim',
|
||||
status: 'booked',
|
||||
bookingDate,
|
||||
valueDate: bookingDate,
|
||||
amountCents: actualPaymentAmountCents,
|
||||
currencyCode: claim.currencyCode || account.currencyCode || 'EUR',
|
||||
reference: payload.reference ? trimText(payload.reference, 255) : `Forderung #${claim.id}`,
|
||||
notes: note || `Zahlung zu Forderung #${claim.id}`,
|
||||
}, { transaction });
|
||||
}
|
||||
});
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async reconcileFromTransactions(clubId, claimId, dbTransaction = null) {
|
||||
const claim = await ClubPaymentClaim.findOne({
|
||||
where: { id: claimId, clubId },
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (['cancelled', 'written_off'].includes(claim.status)) {
|
||||
return claim;
|
||||
}
|
||||
|
||||
const transactions = await ClubAccountTransaction.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
paymentClaimId: claim.id,
|
||||
direction: 'credit',
|
||||
status: 'booked',
|
||||
},
|
||||
order: [['bookingDate', 'ASC'], ['createdAt', 'ASC']],
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
|
||||
const paidAmountCents = transactions.reduce((sum, transaction) => sum + Number(transaction.amountCents || 0), 0);
|
||||
const latestPaymentTransaction = transactions[transactions.length - 1] || null;
|
||||
const nextStatus = deriveStatusFromBalance(Number(claim.amountCents || 0), paidAmountCents);
|
||||
|
||||
await claim.update({
|
||||
paidAmountCents,
|
||||
status: nextStatus,
|
||||
settledAt: nextStatus === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: paidAmountCents > 0
|
||||
? (latestPaymentTransaction?.bookingDate ? new Date(`${latestPaymentTransaction.bookingDate}T00:00:00`) : (claim.lastPaidAt || new Date()))
|
||||
: null,
|
||||
archivedAt: nextStatus === 'paid' ? null : claim.archivedAt,
|
||||
}, dbTransaction ? { transaction: dbTransaction } : undefined);
|
||||
|
||||
return claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
transaction: dbTransaction || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async applyPaymentToClaim(clubId, claimOrId, payment, dbTransaction = null) {
|
||||
const claimId = typeof claimOrId === 'object' && claimOrId ? claimOrId.id : claimOrId;
|
||||
const claim = typeof claimOrId === 'object' && claimOrId ? claimOrId : await ClubPaymentClaim.findOne({
|
||||
where: { id: claimId, clubId },
|
||||
transaction: dbTransaction || undefined,
|
||||
lock: dbTransaction ? Transaction.LOCK.UPDATE : undefined,
|
||||
});
|
||||
|
||||
if (!claim) {
|
||||
const error = new Error('Zahlungsforderung wurde nicht gefunden.');
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (['cancelled', 'written_off', 'paid'].includes(claim.status)) {
|
||||
const error = new Error('Für diesen Status kann keine Zahlung mehr erfasst werden.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const incomingAmountCents = Number.parseInt(payment?.amountCents, 10) || 0;
|
||||
if (incomingAmountCents <= 0) {
|
||||
const error = new Error('Der Zahlungsbetrag muss größer als 0 sein.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const currentPaidAmount = Number(claim.paidAmountCents || 0);
|
||||
const nextPaidAmount = clampPaidAmount(claim.amountCents, payment.paidAmountCents != null
|
||||
? Number(payment.paidAmountCents)
|
||||
: currentPaidAmount + incomingAmountCents);
|
||||
const appliedAmountCents = nextPaidAmount - currentPaidAmount;
|
||||
if (appliedAmountCents <= 0) {
|
||||
const error = new Error('Die Forderung ist bereits vollständig beglichen.');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const nextStatus = payment.status || deriveFinancialStatus(claim.status, claim.amountCents, nextPaidAmount);
|
||||
await claim.update({
|
||||
paidAmountCents: nextPaidAmount,
|
||||
status: nextStatus,
|
||||
settledAt: nextStatus === 'paid' ? (claim.settledAt || new Date()) : null,
|
||||
lastPaidAt: new Date(),
|
||||
notes: payment.notes != null ? payment.notes : claim.notes,
|
||||
lastReminderAt: payment.lastReminderAt != null ? payment.lastReminderAt : claim.lastReminderAt,
|
||||
}, dbTransaction ? { transaction: dbTransaction } : undefined);
|
||||
|
||||
return {
|
||||
claim: await claim.reload({
|
||||
include: [
|
||||
{ model: Member, as: 'member', required: false },
|
||||
{ model: ClubAccountTransaction, as: 'transactions', required: false },
|
||||
],
|
||||
transaction: dbTransaction || undefined,
|
||||
}),
|
||||
appliedAmountCents,
|
||||
nextPaidAmount,
|
||||
nextStatus,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatMoney(amountCents, currencyCode = 'EUR') {
|
||||
return new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: currencyCode || 'EUR',
|
||||
}).format(Number(amountCents || 0) / 100);
|
||||
}
|
||||
|
||||
export default new ClubPaymentClaimService();
|
||||
@@ -74,7 +74,12 @@ class ClubService {
|
||||
autoFetchRankings,
|
||||
countryCode,
|
||||
stateCode,
|
||||
memberDataQualityRequirements
|
||||
memberDataQualityRequirements,
|
||||
feeRules,
|
||||
outgoingInvoicePrefix,
|
||||
outgoingInvoiceNextNumber,
|
||||
incomingInvoicePrefix,
|
||||
incomingInvoiceNextNumber
|
||||
}) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const club = await Club.findByPk(clubId);
|
||||
@@ -89,6 +94,13 @@ class ClubService {
|
||||
if (memberDataQualityRequirements !== undefined) {
|
||||
updates.memberDataQualityRequirements = this.normalizeMemberDataQualityRequirements(memberDataQualityRequirements);
|
||||
}
|
||||
if (feeRules !== undefined) {
|
||||
updates.feeRules = this.normalizeFeeRules(feeRules);
|
||||
}
|
||||
if (outgoingInvoicePrefix !== undefined) updates.outgoingInvoicePrefix = this.normalizeInvoicePrefix(outgoingInvoicePrefix, 'RE');
|
||||
if (incomingInvoicePrefix !== undefined) updates.incomingInvoicePrefix = this.normalizeInvoicePrefix(incomingInvoicePrefix, 'EI');
|
||||
if (outgoingInvoiceNextNumber !== undefined) updates.outgoingInvoiceNextNumber = this.normalizeInvoiceNextNumber(outgoingInvoiceNextNumber);
|
||||
if (incomingInvoiceNextNumber !== undefined) updates.incomingInvoiceNextNumber = this.normalizeInvoiceNextNumber(incomingInvoiceNextNumber);
|
||||
return await club.update(updates);
|
||||
}
|
||||
|
||||
@@ -123,6 +135,45 @@ class ClubService {
|
||||
);
|
||||
}
|
||||
|
||||
normalizeFeeRules(rules) {
|
||||
if (!Array.isArray(rules)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return rules
|
||||
.map((rule, index) => {
|
||||
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const code = String(rule.code || rule.contributionGroupCode || '').trim();
|
||||
const label = String(rule.label || '').trim();
|
||||
const amountCents = Number.parseInt(rule.amountCents ?? Math.round(Number(rule.amountEuro || 0) * 100), 10);
|
||||
const cycle = String(rule.cycle || 'monthly').trim().toLowerCase();
|
||||
const note = String(rule.note || '').trim();
|
||||
|
||||
return {
|
||||
id: String(rule.id || `${Date.now()}-${index}`),
|
||||
code,
|
||||
label,
|
||||
amountCents: Number.isFinite(amountCents) && amountCents >= 0 ? amountCents : 0,
|
||||
cycle: ['monthly', 'quarterly', 'half_yearly', 'yearly', 'one_time'].includes(cycle) ? cycle : 'monthly',
|
||||
note: note || null,
|
||||
};
|
||||
})
|
||||
.filter((rule) => rule && (rule.code || rule.label || rule.amountCents > 0));
|
||||
}
|
||||
|
||||
normalizeInvoicePrefix(prefix, fallback) {
|
||||
const normalized = String(prefix || fallback || '').trim().toUpperCase().slice(0, 24);
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
normalizeInvoiceNextNumber(value) {
|
||||
const numeric = Number.parseInt(value, 10);
|
||||
return Number.isInteger(numeric) && numeric > 0 ? numeric : 1;
|
||||
}
|
||||
|
||||
async approveUserClubAccess(userToken, clubId, toApproveUserId) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const toApproveUserClub = await UserClub.findOne({
|
||||
|
||||
287
backend/services/clubStatisticsService.js
Normal file
287
backend/services/clubStatisticsService.js
Normal file
@@ -0,0 +1,287 @@
|
||||
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 paidAmount = Math.max(0, Number(claim.paidAmountCents || 0));
|
||||
const remainingAmount = Math.max(0, amount - paidAmount);
|
||||
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 += paidAmount || amount;
|
||||
if (monthEntry) {
|
||||
monthEntry.claimPaidCount += 1;
|
||||
monthEntry.claimPaidAmountCents += paidAmount || amount;
|
||||
}
|
||||
} else if (['open', 'partially_paid'].includes(claim.status)) {
|
||||
paymentTotals.openCount += 1;
|
||||
paymentTotals.openAmountCents += remainingAmount;
|
||||
if (dueDate && dueDate < today) {
|
||||
paymentTotals.overdueCount += 1;
|
||||
paymentTotals.overdueAmountCents += remainingAmount;
|
||||
}
|
||||
if (monthEntry) {
|
||||
monthEntry.claimOpenCount += 1;
|
||||
monthEntry.claimOpenAmountCents += remainingAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
826
backend/services/clubTaskAutomationService.js
Normal file
826
backend/services/clubTaskAutomationService.js
Normal file
@@ -0,0 +1,826 @@
|
||||
import { Op } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import {
|
||||
CalendarEvent,
|
||||
ClubCommunicationRecipient,
|
||||
ClubCommunicationThread,
|
||||
ClubDocument,
|
||||
ClubInvoiceParty,
|
||||
ClubInvoice,
|
||||
ClubPaymentClaim,
|
||||
ClubRequest,
|
||||
ClubSepaMandate,
|
||||
ClubTask,
|
||||
ClubTaskSuppression,
|
||||
Member,
|
||||
} from '../models/index.js';
|
||||
import { CLUB_TASK_DEFINITIONS, CLUB_WORKFLOW_SOURCES, getClubTaskDefinitionMap } from './clubTaskDefinitions.js';
|
||||
import { hasClubPaymentClaimPaidAmountCentsColumn } from './clubPaymentClaimCompatibility.js';
|
||||
|
||||
const definitionMap = getClubTaskDefinitionMap();
|
||||
|
||||
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())
|
||||
);
|
||||
}
|
||||
|
||||
function hasTable(availableTables, tableName) {
|
||||
return availableTables.has(String(tableName).toLowerCase());
|
||||
}
|
||||
|
||||
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 formatReminderStage(reminderLevel) {
|
||||
const level = Math.max(0, Number.parseInt(reminderLevel, 10) || 0);
|
||||
if (level <= 0) return 'keine Mahnung';
|
||||
if (level === 1) return '1. Mahnung';
|
||||
if (level === 2) return '2. Mahnung';
|
||||
return 'letzte Mahnung';
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
function documentSuggestionFor(document, today) {
|
||||
const updatedAt = document.updatedAt ? new Date(document.updatedAt) : today;
|
||||
const ageDays = Math.floor((today.getTime() - updatedAt.getTime()) / 86400000);
|
||||
const type = String(document.documentType || '').toLowerCase();
|
||||
const status = String(document.status || '').toLowerCase();
|
||||
|
||||
if (!['satzung', 'protokoll', 'nachweis'].includes(type)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'archived' || status === 'obsolete') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reviewThresholdDays = {
|
||||
satzung: 365,
|
||||
protokoll: 90,
|
||||
nachweis: 30,
|
||||
}[type] || 90;
|
||||
|
||||
if (ageDays < reviewThresholdDays && status !== 'draft') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
taskType: 'document_review_required',
|
||||
title: `${type === 'satzung' ? 'Satzung' : type === 'protokoll' ? 'Protokoll' : 'Nachweis'} prüfen: ${document.title}`,
|
||||
description: 'Wichtige Vereinsdokumente vor Freigabe oder Archivierung prüfen und den aktuellen Stand bestätigen.',
|
||||
priority: type === 'satzung' ? 'high' : 'normal',
|
||||
dueAt: addDays(updatedAt, Math.min(14, reviewThresholdDays)),
|
||||
};
|
||||
}
|
||||
|
||||
function sponsorPartySuggestionFor(party, today) {
|
||||
const status = String(party.status || '').toLowerCase();
|
||||
const validTo = party.validTo ? new Date(party.validTo) : null;
|
||||
const daysLeft = validTo ? daysUntil(validTo, today) : null;
|
||||
|
||||
if (status !== 'active' || !validTo || Number.isNaN(validTo.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (daysLeft > 60) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
taskType: 'sponsor_contract_renewal',
|
||||
title: `Sponsoringvertrag von ${party.name} verlängern`,
|
||||
description: daysLeft <= 0
|
||||
? 'Der Sponsorvertrag ist bereits abgelaufen und sollte umgehend geklärt werden.'
|
||||
: `Der Sponsorvertrag läuft in ${daysLeft} Tagen aus und sollte rechtzeitig verlängert werden.`,
|
||||
priority: daysLeft <= 14 ? 'high' : 'normal',
|
||||
dueAt: validTo,
|
||||
};
|
||||
}
|
||||
|
||||
class ClubTaskAutomationService {
|
||||
async buildAutomationOverview(clubId) {
|
||||
const today = todayStart();
|
||||
const availableTables = await loadAvailableTables();
|
||||
const hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn();
|
||||
const [currentTasks, requests, members, mandates, paymentClaims, invoices, parties, documents, communicationRecipients, events, suppressions] = await Promise.all([
|
||||
ClubTask.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
automationKey: { [Op.ne]: null },
|
||||
},
|
||||
}),
|
||||
hasTable(availableTables, 'club_requests')
|
||||
? ClubRequest.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'in_progress', 'waiting'] },
|
||||
},
|
||||
order: [['receivedAt', 'ASC']],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
Member.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
active: true,
|
||||
},
|
||||
order: [['lastName', 'ASC'], ['firstName', 'ASC']],
|
||||
}),
|
||||
hasTable(availableTables, 'club_sepa_mandates')
|
||||
? ClubSepaMandate.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active',
|
||||
revokedAt: null,
|
||||
memberId: { [Op.ne]: null },
|
||||
},
|
||||
attributes: ['memberId'],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
hasPaidAmountCentsColumn && hasTable(availableTables, 'club_payment_claims')
|
||||
? ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
},
|
||||
order: [['dueOn', 'ASC']],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
hasTable(availableTables, 'club_invoices')
|
||||
? ClubInvoice.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['issued', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
dueOn: { [Op.ne]: null },
|
||||
},
|
||||
order: [['dueOn', 'ASC']],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
hasTable(availableTables, 'club_invoice_parties')
|
||||
? ClubInvoiceParty.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
partyType: 'sponsor',
|
||||
},
|
||||
order: [['status', 'ASC'], ['validTo', 'ASC'], ['name', 'ASC']],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
hasTable(availableTables, 'club_documents')
|
||||
? ClubDocument.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
documentType: { [Op.in]: ['satzung', 'protokoll', 'nachweis'] },
|
||||
status: { [Op.in]: ['active', 'draft'] },
|
||||
},
|
||||
order: [['updatedAt', 'DESC']],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
hasTable(availableTables, 'club_communication_recipients') && hasTable(availableTables, 'club_communication_threads')
|
||||
? ClubCommunicationRecipient.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
deliveryStatus: 'failed',
|
||||
retryable: true,
|
||||
},
|
||||
include: [
|
||||
{ model: ClubCommunicationThread, as: 'thread', required: false },
|
||||
],
|
||||
order: [['updatedAt', 'DESC']],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
hasTable(availableTables, 'calendar_events')
|
||||
? CalendarEvent.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
endDate: { [Op.gte]: today.toISOString().slice(0, 10) },
|
||||
},
|
||||
order: [['startDate', 'ASC']],
|
||||
limit: 20,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
hasTable(availableTables, 'club_task_suppressions')
|
||||
? ClubTaskSuppression.findAll({
|
||||
where: { clubId },
|
||||
attributes: ['automationKey', 'suppressionToken'],
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
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 document of documents) {
|
||||
const suggestion = documentSuggestionFor(document, today);
|
||||
if (!suggestion) continue;
|
||||
const key = buildAutomationKey(suggestion.taskType, 'club_document', document.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
...suggestion,
|
||||
automationSource: 'club_documents',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([document.updatedAt, document.status, document.currentVersionNo, document.title]),
|
||||
sourceEntityType: 'club_document',
|
||||
sourceEntityId: document.id,
|
||||
sourceSnapshot: {
|
||||
documentType: document.documentType,
|
||||
status: document.status,
|
||||
title: document.title,
|
||||
currentVersionNo: document.currentVersionNo,
|
||||
archivedAt: document.archivedAt || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const party of parties) {
|
||||
const suggestion = sponsorPartySuggestionFor(party, today);
|
||||
if (!suggestion) continue;
|
||||
const key = buildAutomationKey(suggestion.taskType, 'club_invoice_party', party.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
...suggestion,
|
||||
automationSource: 'club_invoice_parties',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([party.updatedAt, party.status, party.validTo, party.contractReference]),
|
||||
sourceEntityType: 'club_invoice_party',
|
||||
sourceEntityId: party.id,
|
||||
sourceSnapshot: {
|
||||
partyName: party.name,
|
||||
status: party.status,
|
||||
contractReference: party.contractReference || null,
|
||||
validTo: party.validTo || 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 remainingAmountCents = Math.max(0, Number(claim.amountCents || 0) - Number(claim.paidAmountCents || 0));
|
||||
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'
|
||||
? `${formatReminderStage(claim.reminderLevel)} für Forderung ${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 Restforderung über ${(remainingAmountCents / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} mit ${formatReminderStage(claim.reminderLevel)} prüfen.`
|
||||
: claimTaskType === 'payment_claim_overdue'
|
||||
? `Überfällige Restforderung über ${(remainingAmountCents / 100).toFixed(2)} ${claim.currencyCode || 'EUR'} priorisiert nachverfolgen.`
|
||||
: `Restforderung über ${(remainingAmountCents / 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),
|
||||
paidAmountCents: Number(claim.paidAmountCents || 0),
|
||||
remainingAmountCents,
|
||||
currencyCode: claim.currencyCode,
|
||||
dueOn: claim.dueOn,
|
||||
status: claim.status,
|
||||
reminderLevel: claim.reminderLevel,
|
||||
reminderStage: formatReminderStage(claim.reminderLevel),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const invoice of invoices) {
|
||||
const dueDate = invoice.dueOn ? new Date(invoice.dueOn) : today;
|
||||
const invoiceTaskType = invoice.invoiceDirection === 'incoming'
|
||||
? (daysUntil(dueDate, today) < 0 ? 'invoice_incoming_overdue' : 'invoice_incoming_due_soon')
|
||||
: (daysUntil(dueDate, today) < 0 ? 'invoice_outgoing_overdue' : 'invoice_outgoing_due_soon');
|
||||
const key = buildAutomationKey(invoiceTaskType, 'club_invoice', invoice.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
taskType: invoiceTaskType,
|
||||
title: `${invoice.invoiceDirection === 'incoming' ? 'Eingangsrechnung' : 'Ausgangsrechnung'} ${invoice.invoiceNumber || `#${invoice.id}`} prüfen`,
|
||||
description: invoice.invoiceDirection === 'incoming'
|
||||
? 'Offene Eingangsrechnung rechtzeitig für Zahlung oder Klärung vorbereiten.'
|
||||
: 'Offene Ausgangsrechnung auf Zahlungseingang und Erinnerung prüfen.',
|
||||
priority: derivePriority(daysUntil(dueDate, today)),
|
||||
dueAt: dueDate,
|
||||
automationSource: 'club_invoices',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([invoice.updatedAt, invoice.status, invoice.dueOn, invoice.grossAmountCents]),
|
||||
sourceEntityType: 'club_invoice',
|
||||
sourceEntityId: invoice.id,
|
||||
sourceSnapshot: {
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
invoiceDirection: invoice.invoiceDirection,
|
||||
amountCents: Number(invoice.grossAmountCents || 0),
|
||||
currencyCode: invoice.currencyCode || 'EUR',
|
||||
dueOn: invoice.dueOn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of communicationRecipients) {
|
||||
const key = buildAutomationKey('communication_delivery_retry', 'club_communication_recipient', recipient.id);
|
||||
if (existingKeys.has(key)) continue;
|
||||
pushSuggestion({
|
||||
taskType: 'communication_delivery_retry',
|
||||
title: `Versandfehler für ${recipient.recipientName || 'Empfänger'} prüfen`,
|
||||
description: recipient.errorMessage || 'Retry-fähiger Versandfehler in der Vereinskommunikation.',
|
||||
priority: 'high',
|
||||
dueAt: addDays(today, 1),
|
||||
automationSource: 'club_communication',
|
||||
automationKey: key,
|
||||
suppressionToken: suggestionToken([recipient.updatedAt, recipient.errorCode, recipient.errorMessage]),
|
||||
sourceEntityType: 'club_communication_recipient',
|
||||
sourceEntityId: recipient.id,
|
||||
sourceSnapshot: {
|
||||
recipientName: recipient.recipientName,
|
||||
threadId: recipient.threadId,
|
||||
threadSubject: recipient.thread?.subject || null,
|
||||
errorMessage: recipient.errorMessage || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const event of events) {
|
||||
if (['cancelled', 'done'].includes(String(event.status || ''))) {
|
||||
continue;
|
||||
}
|
||||
if (hasExistingSourceTask(currentTasks, 'calendar_event', event.id, 'calendar_events')) continue;
|
||||
const referenceDate = event.registrationDeadline || event.startDate;
|
||||
const dueDate = referenceDate ? addDays(new Date(referenceDate), -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.registrationDeadline, event.status, event.eventType]),
|
||||
sourceEntityType: 'calendar_event',
|
||||
sourceEntityId: event.id,
|
||||
sourceSnapshot: {
|
||||
title: event.title,
|
||||
eventType: event.eventType || null,
|
||||
status: event.status || null,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
registrationDeadline: event.registrationDeadline || null,
|
||||
location: event.location || null,
|
||||
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,
|
||||
workflowSources: CLUB_WORKFLOW_SOURCES,
|
||||
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,
|
||||
});
|
||||
case 'sponsoring_prepare_offer':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `Sponsoringangebot für ${person} vorbereiten`,
|
||||
description: 'Konkretes Sponsoringangebot auf Basis des Erstkontakts zusammenstellen.',
|
||||
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 'sponsoring_follow_up':
|
||||
return wrapSuggestion({
|
||||
taskType: nextTaskType,
|
||||
title: `Sponsoringnachfassen bei ${person}`,
|
||||
description: 'Nach Versand des Angebots Rückmeldung einholen und den nächsten Schritt abstimmen.',
|
||||
priority: 'normal',
|
||||
dueAt: addDays(sourceDate, 4),
|
||||
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();
|
||||
352
backend/services/clubTaskDefinitions.js
Normal file
352
backend/services/clubTaskDefinitions.js
Normal file
@@ -0,0 +1,352 @@
|
||||
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: ['sponsoring_prepare_offer'],
|
||||
},
|
||||
{
|
||||
key: 'sponsoring_prepare_offer',
|
||||
label: 'Sponsoringangebot vorbereiten',
|
||||
source: 'club_requests',
|
||||
category: 'Sponsoring',
|
||||
workflow: 'Sponsoring',
|
||||
trigger: 'Erstkontakt ist erfolgt und ein konkretes Angebot soll vorbereitet werden.',
|
||||
description: 'Sponsoringpaket, Leistungen und Konditionen für den nächsten Kontakt bündeln.',
|
||||
suggestedAction: 'Leistungen abstimmen und Angebot oder Mustervertrag vorbereiten.',
|
||||
nextTaskTypes: ['sponsoring_follow_up'],
|
||||
},
|
||||
{
|
||||
key: 'sponsoring_follow_up',
|
||||
label: 'Nach Sponsoringangebot nachfassen',
|
||||
source: 'club_requests',
|
||||
category: 'Sponsoring',
|
||||
workflow: 'Sponsoring',
|
||||
trigger: 'Sponsoringangebot wurde versendet und eine Rückmeldung steht noch aus.',
|
||||
description: 'Nachfassen, Rückfragen klären und bei Zusage den Übergang in die Rechnungsanlage vorbereiten.',
|
||||
suggestedAction: 'Rückmeldung einholen und Rechnungs- oder Vertragsstart anstoßen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'document_review_required',
|
||||
label: 'Dokument prüfen und freigeben',
|
||||
source: 'club_documents',
|
||||
category: 'Dokumente',
|
||||
workflow: 'Dokumentenpflege',
|
||||
trigger: 'Satzung, Protokoll oder Nachweis wartet auf Prüfung oder Aktualisierung.',
|
||||
description: 'Wichtige Vereinsdokumente nach Änderungen oder in regelmäßigen Abständen prüfen.',
|
||||
suggestedAction: 'Inhalt prüfen, Freigabe dokumentieren und Version sauber archivieren.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'sponsor_contract_renewal',
|
||||
label: 'Sponsoringvertrag verlängern',
|
||||
source: 'club_invoice_parties',
|
||||
category: 'Sponsoring',
|
||||
workflow: 'Sponsoring',
|
||||
trigger: 'Ein Sponsoringvertrag läuft bald aus.',
|
||||
description: 'Laufenden Sponsorvertrag rechtzeitig prüfen und die Verlängerung oder Nachverhandlung vorbereiten.',
|
||||
suggestedAction: 'Kontakt aufnehmen, Laufzeit abstimmen und Vertragsverlängerung vorbereiten.',
|
||||
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: 'invoice_outgoing_due_soon',
|
||||
label: 'Ausgangsrechnung nachverfolgen',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Ausgangsrechnung ist gestellt und bald fällig.',
|
||||
description: 'Offene Ausgangsrechnung kurz vor Fälligkeit aktiv beobachten und Kontakt vorbereiten.',
|
||||
suggestedAction: 'Zahlungseingang prüfen oder Erinnerung vorbereiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'invoice_outgoing_overdue',
|
||||
label: 'Überfällige Ausgangsrechnung nachfassen',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Ausgangsrechnung ist überfällig und noch nicht bezahlt.',
|
||||
description: 'Überfällige Forderung im Vereinskontext verfolgen und weitere Schritte einleiten.',
|
||||
suggestedAction: 'Erinnerung senden, Sponsorenkontakt aufnehmen oder Klärung einleiten.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'invoice_incoming_due_soon',
|
||||
label: 'Eingangsrechnung einplanen',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Eingangsrechnung ist bald fällig.',
|
||||
description: 'Offene Eingangsrechnung rechtzeitig für Zahlung und Freigabe vorbereiten.',
|
||||
suggestedAction: 'Konto prüfen, Freigabe sichern und Zahlung einplanen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'invoice_incoming_overdue',
|
||||
label: 'Überfällige Eingangsrechnung klären',
|
||||
source: 'club_invoices',
|
||||
category: 'Finanzen',
|
||||
workflow: 'Rechnungen',
|
||||
trigger: 'Eingangsrechnung ist überfällig.',
|
||||
description: 'Überfällige Eingangsrechnung auf offenen Zahlungsbedarf oder Klärung prüfen.',
|
||||
suggestedAction: 'Zahlung veranlassen oder Lieferantenkontakt aufnehmen.',
|
||||
nextTaskTypes: [],
|
||||
},
|
||||
{
|
||||
key: 'communication_delivery_retry',
|
||||
label: 'Versandfehler nachfassen',
|
||||
source: 'club_communication',
|
||||
category: 'Kommunikation',
|
||||
workflow: 'Versand',
|
||||
trigger: 'Kommunikationsvorgang hat retry-fähige Zustellfehler.',
|
||||
description: 'Fehlgeschlagene Zustellung prüfen, Empfängerdaten korrigieren oder erneuten Versand auslösen.',
|
||||
suggestedAction: 'Fehler prüfen, E-Mail korrigieren und erneut senden.',
|
||||
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 const CLUB_WORKFLOW_SOURCES = [
|
||||
{
|
||||
key: 'club_requests',
|
||||
label: 'Anfragen',
|
||||
description: 'Kontakt-, Probe- und Mitgliedsanfragen erzeugen Aufgaben entlang des Aufnahme-Workflows.',
|
||||
examples: ['Kontaktanfrage beantworten', 'Probetraining organisieren', 'Mitgliedsanfrage prüfen'],
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
label: 'Mitgliederdaten',
|
||||
description: 'Fehlende Stammdaten wie E-Mail oder Geburtsdatum werden als Datenqualitäts-Aufgaben erkannt.',
|
||||
examples: ['E-Mail ergänzen', 'Geburtsdatum ergänzen'],
|
||||
},
|
||||
{
|
||||
key: 'club_sepa_mandates',
|
||||
label: 'SEPA-Mandate',
|
||||
description: 'Fehlende oder notwendige SEPA-Mandate werden als Finanz- und Onboarding-Aufgaben erzeugt.',
|
||||
examples: ['SEPA-Mandat einholen'],
|
||||
},
|
||||
{
|
||||
key: 'club_payment_claims',
|
||||
label: 'Forderungen',
|
||||
description: 'Offene, fällige und gemahnte Beitragsforderungen erzeugen Nachfass- und Mahnaufgaben.',
|
||||
examples: ['Fällige Zahlung vorbereiten', 'Mahnstufe prüfen'],
|
||||
},
|
||||
{
|
||||
key: 'club_invoices',
|
||||
label: 'Rechnungen',
|
||||
description: 'Offene Ausgangs- und Eingangsrechnungen erzeugen finanzielle Wiedervorlagen entlang der Fälligkeit.',
|
||||
examples: ['Ausgangsrechnung nachverfolgen', 'Eingangsrechnung einplanen'],
|
||||
},
|
||||
{
|
||||
key: 'club_documents',
|
||||
label: 'Dokumente',
|
||||
description: 'Satzungen, Protokolle und Nachweise erzeugen Prüf- und Freigabeaufgaben.',
|
||||
examples: ['Dokument prüfen und freigeben'],
|
||||
},
|
||||
{
|
||||
key: 'club_invoice_parties',
|
||||
label: 'Sponsoren',
|
||||
description: 'Laufende Sponsorenbeziehungen erzeugen Vertragsverlängerungen und Nachfassaufgaben.',
|
||||
examples: ['Sponsoringvertrag verlängern', 'Sponsoringangebot vorbereiten'],
|
||||
},
|
||||
{
|
||||
key: 'club_communication',
|
||||
label: 'Kommunikation',
|
||||
description: 'Retry-fähige Versandfehler werden als Kommunikationsaufgaben sichtbar gemacht.',
|
||||
examples: ['Versandfehler nachfassen'],
|
||||
},
|
||||
{
|
||||
key: 'calendar_events',
|
||||
label: 'Termine',
|
||||
description: 'Bevorstehende Termine und Fristen erzeugen organisatorische Vorbereitungsaufgaben.',
|
||||
examples: ['Termin vorbereiten', 'Terminfrist prüfen'],
|
||||
},
|
||||
];
|
||||
|
||||
export function getClubTaskDefinitionMap() {
|
||||
return CLUB_TASK_DEFINITIONS.reduce((accumulator, definition) => {
|
||||
accumulator[definition.key] = definition;
|
||||
return accumulator;
|
||||
}, {});
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import ClubTeam from '../models/ClubTeam.js';
|
||||
import ClubTeamMember from '../models/ClubTeamMember.js';
|
||||
import League from '../models/League.js';
|
||||
import Member from '../models/Member.js';
|
||||
import Season from '../models/Season.js';
|
||||
import {
|
||||
ClubTeam,
|
||||
ClubTeamMember,
|
||||
League,
|
||||
Member,
|
||||
Season
|
||||
} from '../models/index.js';
|
||||
import SeasonService from './seasonService.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
@@ -190,16 +192,38 @@ class ClubTeamService {
|
||||
|
||||
static async getTeamLineup(clubTeamId, lineupHalf = 'first_half') {
|
||||
try {
|
||||
return await ClubTeamMember.findAll({
|
||||
const lineupEntries = await ClubTeamMember.findAll({
|
||||
where: { clubTeamId, lineupHalf },
|
||||
include: [
|
||||
{
|
||||
model: Member,
|
||||
as: 'member'
|
||||
}
|
||||
],
|
||||
order: [['position', 'ASC']]
|
||||
});
|
||||
|
||||
if (lineupEntries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const memberIds = [...new Set(
|
||||
lineupEntries
|
||||
.map((entry) => Number(entry.memberId))
|
||||
.filter((memberId) => Number.isInteger(memberId) && memberId > 0)
|
||||
)];
|
||||
|
||||
const members = memberIds.length > 0
|
||||
? await Member.findAll({
|
||||
where: { id: memberIds }
|
||||
})
|
||||
: [];
|
||||
|
||||
const memberById = new Map(
|
||||
members.map((member) => [Number(member.id), member])
|
||||
);
|
||||
|
||||
return lineupEntries.map((entry) => {
|
||||
const plainEntry = entry.get({ plain: true });
|
||||
return {
|
||||
...plainEntry,
|
||||
member: memberById.get(Number(entry.memberId)) || null
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isMissingTeamLineupTable(error)) {
|
||||
return [];
|
||||
|
||||
69
backend/services/clubWorkflowSourceService.js
Normal file
69
backend/services/clubWorkflowSourceService.js
Normal file
@@ -0,0 +1,69 @@
|
||||
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' };
|
||||
case 'sponsoring_prepare_offer':
|
||||
return { status: 'waiting', workflowStage: 'sponsoring_offer_prepared' };
|
||||
case 'sponsoring_follow_up':
|
||||
return { status: 'waiting', workflowStage: 'sponsoring_followed_up' };
|
||||
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();
|
||||
@@ -1,27 +1,49 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: 'Gmail',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
let transporter = null;
|
||||
|
||||
function getTransporter() {
|
||||
if (!process.env.EMAIL_USER || !process.env.EMAIL_PASS) {
|
||||
const error = new Error('E-Mail-Versand ist nicht konfiguriert.');
|
||||
error.code = 'EMAIL_CONFIG_MISSING';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!transporter) {
|
||||
transporter = nodemailer.createTransport({
|
||||
service: 'Gmail',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return transporter;
|
||||
}
|
||||
|
||||
function getDefaultFrom() {
|
||||
return process.env.EMAIL_FROM || process.env.EMAIL_USER;
|
||||
}
|
||||
|
||||
async function sendMail(mailOptions) {
|
||||
return getTransporter().sendMail({
|
||||
from: getDefaultFrom(),
|
||||
...mailOptions,
|
||||
});
|
||||
}
|
||||
|
||||
const sendActivationEmail = async (email, activationCode) => {
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Account Activation',
|
||||
text: `Activate your account by clicking the following link: ${process.env.BASE_URL}/activate/${activationCode}`,
|
||||
};
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
const sendPasswordResetEmail = async (email, resetToken) => {
|
||||
const resetLink = `${process.env.BASE_URL}/reset-password/${resetToken}`;
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Passwort zurücksetzen',
|
||||
html: `
|
||||
@@ -46,8 +68,7 @@ const sendPasswordResetEmail = async (email, resetToken) => {
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
const sendFriendlyMatchInvitationEmail = async ({
|
||||
@@ -68,8 +89,7 @@ const sendFriendlyMatchInvitationEmail = async ({
|
||||
? `<p style="margin-top: 12px;"><strong>Nachricht:</strong><br>${String(message).replace(/</g, '<').replace(/>/g, '>')}</p>`
|
||||
: '';
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: recipientList.join(','),
|
||||
subject: `Freundschaftsspiel-Einladung: ${fromClubName} -> ${toClubName}`,
|
||||
html: `
|
||||
@@ -90,9 +110,7 @@ const sendFriendlyMatchInvitationEmail = async ({
|
||||
</p>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
const escapeHtml = (value) => String(value ?? '')
|
||||
@@ -110,8 +128,7 @@ const sendMobileFeedbackEmail = async ({
|
||||
backendBaseUrl,
|
||||
user,
|
||||
}) => {
|
||||
const mailOptions = {
|
||||
from: process.env.EMAIL_USER,
|
||||
await sendMail({
|
||||
to: 'tsschulz2001@gmail.com',
|
||||
subject: `Android Feedback${screen ? ` - ${screen}` : ''}`,
|
||||
html: `
|
||||
@@ -129,9 +146,23 @@ const sendMobileFeedbackEmail = async ({
|
||||
<div style="white-space:pre-wrap;background:#f9fafb;border:1px solid #e5e7eb;border-radius:8px;padding:12px;">${escapeHtml(message)}</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
|
||||
await transporter.sendMail(mailOptions);
|
||||
});
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail };
|
||||
const sendClubCommunicationEmail = async ({
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
replyTo,
|
||||
}) => {
|
||||
return sendMail({
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
replyTo: replyTo || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export { sendActivationEmail, sendPasswordResetEmail, sendFriendlyMatchInvitationEmail, sendMobileFeedbackEmail, sendClubCommunicationEmail };
|
||||
|
||||
@@ -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 || ''))) {
|
||||
@@ -165,7 +292,8 @@ class MemberService {
|
||||
}
|
||||
|
||||
async setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate, phone, email, active = true, testMembership = false,
|
||||
picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false, adultReleaseApproved = false, adultReserveApproved = false, contacts = []) {
|
||||
picsInInternetAllowed = false, gender = 'unknown', ttr = null, qttr = null, memberFormHandedOver = false, adultReleaseApproved = false, adultReserveApproved = false,
|
||||
contributionGroupCode = null, contacts = []) {
|
||||
try {
|
||||
await checkAccess(userToken, clubId);
|
||||
let member = null;
|
||||
@@ -173,6 +301,7 @@ class MemberService {
|
||||
member = await Member.findOne({ where: { id: memberId } });
|
||||
}
|
||||
const MemberContact = (await import('../models/MemberContact.js')).default;
|
||||
const normalizedContributionGroupCode = String(contributionGroupCode || '').trim() || null;
|
||||
if (member) {
|
||||
member.firstName = firstName;
|
||||
member.lastName = lastName;
|
||||
@@ -191,6 +320,7 @@ class MemberService {
|
||||
member.memberFormHandedOver = !!memberFormHandedOver;
|
||||
member.adultReleaseApproved = !!adultReleaseApproved;
|
||||
member.adultReserveApproved = !!adultReserveApproved;
|
||||
member.contributionGroupCode = normalizedContributionGroupCode;
|
||||
await member.save();
|
||||
|
||||
// Update contacts if provided
|
||||
@@ -236,6 +366,7 @@ class MemberService {
|
||||
memberFormHandedOver: !!memberFormHandedOver,
|
||||
adultReleaseApproved: !!adultReleaseApproved,
|
||||
adultReserveApproved: !!adultReserveApproved,
|
||||
contributionGroupCode: normalizedContributionGroupCode,
|
||||
});
|
||||
|
||||
// Create contacts if provided
|
||||
|
||||
@@ -1,393 +1,642 @@
|
||||
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 },
|
||||
members: { read: true, write: true, delete: true },
|
||||
requests: { read: true, write: true, delete: true },
|
||||
tasks: { read: true, write: true, delete: true },
|
||||
teams: { read: true, write: true, delete: true },
|
||||
schedule: { read: true, write: true, delete: true },
|
||||
tournaments: { read: true, write: true, delete: true },
|
||||
statistics: { read: true, write: true },
|
||||
finance_accounts: { read: true, write: true, delete: true },
|
||||
finance_invoices: { read: true, write: true, delete: true },
|
||||
history: { read: true, write: true },
|
||||
archive: { 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 },
|
||||
communication: { read: true, write: true, delete: 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 },
|
||||
members: { read: true, write: true, delete: false },
|
||||
requests: { read: true, write: true, delete: false },
|
||||
tasks: { read: true, write: true, delete: false },
|
||||
teams: { read: true, write: true, delete: false },
|
||||
schedule: { read: true, write: false, delete: false },
|
||||
tournaments: { read: true, write: true, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: true, delete: 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 },
|
||||
members: { read: true, write: false, delete: false },
|
||||
requests: { read: true, write: false, delete: false },
|
||||
tasks: { read: true, write: true, delete: false },
|
||||
teams: { read: true, write: true, delete: false },
|
||||
schedule: { read: true, write: true, delete: false },
|
||||
tournaments: { read: true, write: false, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: false, delete: 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 },
|
||||
members: { read: true, write: false, delete: false },
|
||||
requests: { read: true, write: false, delete: false },
|
||||
tasks: { read: true, write: false, delete: false },
|
||||
teams: { read: false, write: false, delete: false },
|
||||
schedule: { read: false, write: false, delete: false },
|
||||
tournaments: { read: true, write: true, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: false, delete: false },
|
||||
mytischtennis_admin: { read: false, write: false },
|
||||
predefined_activities: { read: false, write: false, delete: false }
|
||||
predefined_activities: { read: false, write: false, delete: false },
|
||||
},
|
||||
member: {
|
||||
cashier: {
|
||||
diary: { read: false, write: false, delete: false },
|
||||
members: { read: false, write: false, delete: false },
|
||||
members: { read: true, write: false, delete: false },
|
||||
requests: { read: true, write: false, delete: false },
|
||||
tasks: { read: true, write: true, delete: false },
|
||||
teams: { read: false, write: false, delete: false },
|
||||
schedule: { read: false, write: false, delete: false },
|
||||
tournaments: { read: false, write: false, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: true, write: true, delete: false },
|
||||
finance_invoices: { read: true, write: true, delete: false },
|
||||
history: { read: true, write: false },
|
||||
archive: { read: true, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: true, write: false, delete: 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 },
|
||||
members: { read: false, write: false, delete: false },
|
||||
requests: { read: false, write: false, delete: false },
|
||||
tasks: { read: false, write: false, delete: false },
|
||||
teams: { read: false, write: false, delete: false },
|
||||
schedule: { read: false, write: false, delete: false },
|
||||
tournaments: { read: false, write: false, delete: false },
|
||||
statistics: { read: true, write: false },
|
||||
finance_accounts: { read: false, write: false, delete: false },
|
||||
finance_invoices: { read: false, write: false, delete: false },
|
||||
history: { read: false, write: false },
|
||||
archive: { read: false, write: false },
|
||||
settings: { read: false, write: false },
|
||||
permissions: { read: false, write: false },
|
||||
approvals: { read: false, write: false },
|
||||
communication: { read: false, write: false, delete: false },
|
||||
mytischtennis_admin: { read: false, write: 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: 'cashier', name: 'Kassierer', description: 'Kann Konten, Rechnungen und finanznahe Aufgaben verwalten', permissions: ROLE_PERMISSIONS.cashier, sortOrder: 45 },
|
||||
{ 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'] },
|
||||
members: { label: 'Mitglieder', actions: ['read', 'write', 'delete'] },
|
||||
requests: { label: 'Anfragen', actions: ['read', 'write', 'delete'] },
|
||||
tasks: { label: 'Aufgaben', actions: ['read', 'write', 'delete'] },
|
||||
teams: { label: 'Teams', actions: ['read', 'write', 'delete'] },
|
||||
schedule: { label: 'Spielpläne', actions: ['read', 'write', 'delete'] },
|
||||
tournaments: { label: 'Turniere', actions: ['read', 'write', 'delete'] },
|
||||
statistics: { label: 'Statistiken', actions: ['read', 'write'] },
|
||||
finance_accounts: { label: 'Konten', actions: ['read', 'write', 'delete'] },
|
||||
finance_invoices: { label: 'Rechnungen', actions: ['read', 'write', 'delete'] },
|
||||
history: { label: 'Historie', actions: ['read', 'write'] },
|
||||
archive: { label: 'Archiv', actions: ['read', 'write'] },
|
||||
settings: { label: 'Einstellungen', actions: ['read', 'write'] },
|
||||
permissions: { label: 'Berechtigungsverwaltung', actions: ['read', 'write'] },
|
||||
approvals: { label: 'Freigaben (Mitgliedsanträge)', actions: ['read', 'write'] },
|
||||
communication: { label: 'Kommunikation', actions: ['read', 'write', 'delete'] },
|
||||
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, created] = 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,
|
||||
},
|
||||
});
|
||||
|
||||
if (!created && role.isSystemRole) {
|
||||
const mergedPermissions = this.mergePermissions(template.permissions, role.permissions);
|
||||
await role.update({
|
||||
name: role.name || template.name,
|
||||
description: role.description || template.description,
|
||||
permissions: mergedPermissions,
|
||||
sortOrder: role.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();
|
||||
|
||||
|
||||
63
docs/TODO.md
63
docs/TODO.md
@@ -1,6 +1,6 @@
|
||||
# TODO
|
||||
|
||||
Stand: 2026-03-17
|
||||
Stand: 2026-06-22
|
||||
|
||||
## Abgearbeitet
|
||||
|
||||
@@ -9,13 +9,68 @@ Stand: 2026-03-17
|
||||
- [x] Sichtbare UI-Konsistenz an Diary-Mobile-Tabs und Logs-Ansicht nachgezogen.
|
||||
- [x] Live-SQL fuer neue Felder und manuelle Migrationen dokumentiert.
|
||||
- [x] Scheduler- und `match_results`-Ablauf dokumentiert.
|
||||
- [x] Produkttrennung fuer `tt-verein.de` und `mein-tt.de` technisch eingefuehrt.
|
||||
- [x] Vereinsnavigation und Routing fuer das Club-Produkt produkt- und rechtebasiert aufgebaut.
|
||||
- [x] Dashboard von Dummy-Daten auf echte Vereinsdaten fuer Aufgaben, Mitglieder, Termine und fehlende Daten umgestellt.
|
||||
- [x] Aufgabenmodul mit echten Aufgaben, automatischen Vorschlaegen, Ausblenden, Archivieren, Loeschen und Benutzerzuordnung umgesetzt.
|
||||
- [x] Rollen und Benutzer fuer Vereine eingefuehrt, inklusive Mehrfachrollen und menuewirksamer Rechtepruefung.
|
||||
- [x] Historie fuer Vereinsbereiche umgesetzt und Aenderungen an Rollen und Benutzerzuordnungen aufgenommen.
|
||||
- [x] Mitgliederbereich um Bankkonto-/SEPA-relevante Vereinsdaten erweitert.
|
||||
- [x] Statistiken als echte, einklappbare Vereinsauswertungen umgesetzt.
|
||||
- [x] Archiv als echte Vereinsansicht umgesetzt.
|
||||
- [x] Konten und Rechnungen als echte Vereinsmodule aufgebaut.
|
||||
- [x] Kommunikation als Vereinsmodul mit Vorgaengen, Verteilergruppen, Empfaengerlogik und Versandstatus umgesetzt.
|
||||
- [x] Echten Mailversand fuer Kommunikation mit retry-faehiger Fehlerklassifikation, Versandprotokoll und sichtbaren Fehlermeldungen eingebaut.
|
||||
- [x] Rechtebasierte Club-Navigation auf fachlich passende Module und Berechtigungen fuer Aufgaben, Kommunikation, Historie, Archiv, Konten und Rechnungen nachgezogen.
|
||||
|
||||
## Weiter spaeter sinnvoll
|
||||
## Teilweise umgesetzt / weiter ausbauen
|
||||
|
||||
- [x] Groeßere Views weiter komponentisieren, vor allem `DiaryView.vue`, `MembersView.vue`, `TeamManagementView.vue`.
|
||||
- [x] Verbleibende selten genutzte Alt-Styles in Spezialviews und Demo-Komponenten angleichen.
|
||||
- [x] Diary-Sonderfaelle weiter schaerfen, z.B. eigene Filterchips fuer entschuldigte Teilnehmer.
|
||||
- [x] Club-Views weiter haerten: Read-only-Verhalten, Reload-Zustaende und Formular-Resets in Aufgaben, Kommunikation, Konten und Rechnungen weiter auf Kantenfaelle pruefen.
|
||||
- [ ] Kommunikation: SMTP-Konfiguration produktiv pruefen, reale Zustellung testen und optional Antwortadressen pro Verein nachziehen.
|
||||
- [x] Rechnungen: automatische Nummernvergabe aus Vereineinstellungen fertig verdrahten und weiter absichern.
|
||||
- [x] Aufgabenautomatisierung weiter ausbauen, damit noch mehr Vereinsprozesse automatisch Folgeschritte erzeugen.
|
||||
- [x] Dashboard weiter verdichten, damit neue Kommunikations-, Finanz- und Archivdaten direkter sichtbar werden.
|
||||
- [x] Beitraege/Zahlungen operativ zuerst verdichten statt sofort Regelwerk bauen:
|
||||
- [x] Mitglieder sauber mit Beitragsgruppe und Zahlungsbezug sichtbar verknuepfen.
|
||||
- [x] In der Beitraege-Ansicht pro Mitglied offene Forderungen, letzten Status und fehlende Zuordnungen sichtbar machen.
|
||||
- [x] In der Zahlungen-Ansicht Forderungen, Mahnstufen und offene Aktionen als taegliche Arbeitsliste nutzbar machen.
|
||||
- [x] Danach einfache manuelle Beitragslogik fuer typische Vereinsfaelle ergaenzen.
|
||||
- [ ] Erst spaeter ein allgemeines Regel-/Tarifsystem mit Familienregeln, Alterslogik und Gueltigkeitszeitraeumen bauen.
|
||||
|
||||
## Naechste Liste
|
||||
## Naechste Prioritaeten
|
||||
|
||||
Die neue priorisierte Restliste steht in [OPTIMIZATION_TODO.md](./OPTIMIZATION_TODO.md).
|
||||
- [x] Kommunikation: Empfaengerlogik weiter ausbauen, Versandvorlagen, Verteilerfilter und Antworten dokumentieren.
|
||||
- [x] Finanzen: Ausgangs- und Eingangsrechnungen weiter vervollstaendigen, Kontenbewegungen anbinden.
|
||||
- [x] Vereinsbenutzer: feinere Rechte, Rollenpflege und weitere Menueeinschraenkungen vervollstaendigen.
|
||||
- [x] Automatisierte Vereinsprozesse definieren und technisch als stabile Aufgabenquellen hinterlegen.
|
||||
- [x] Beitraege/Zahlungen: zuerst Mitglied -> Beitragszuordnung -> Forderung -> Zahlung als durchgehenden Vereinsprozess fertigziehen.
|
||||
- [x] Beitraege: Mitgliederliste mit Beitragsgruppe, offenen Forderungen und fehlenden Zuordnungen verdichten.
|
||||
- [x] Zahlungen: Forderungen, Statuswechsel und Mahnlogik weiter zu einem echten Vereinsarbeitsbereich ausbauen.
|
||||
- [ ] Player-Produkt `mein-tt.de` inhaltlich ausbauen, jetzt wo die Produkttrennung steht.
|
||||
|
||||
## Weiter spaeter sinnvoll
|
||||
|
||||
- [ ] Historie feiner filtern, exportieren und moduluebergreifend verlinken.
|
||||
- [ ] Kommunikation um Dokumentanhaenge und Serienvorlagen erweitern.
|
||||
- [ ] Vereinsarchiv um weitere Entitaeten und komfortablere Suche erweitern.
|
||||
- Detailplan fuer Club-Ausbau und Absicherung: [club-outstanding-plan.md](./club-outstanding-plan.md).
|
||||
- [ ] Die alte Optimierungs-Restliste bei Bedarf mit [OPTIMIZATION_TODO.md](./OPTIMIZATION_TODO.md) zusammenfuehren.
|
||||
|
||||
## Fehlend
|
||||
|
||||
- [ ] Club-Produkt: Die folgenden sechs Vereinsmodule sind in der Struktur vorhanden, aber noch nicht als echte Arbeitsbereiche umgesetzt.
|
||||
- [x] Dokumente: Upload, Ordner-/Ablagestruktur, Belegbezug und schnelle Suche als echter Vereins-Dokumentenbereich.
|
||||
- [x] Dokumente: Vorlagen, Versionierung und Archivierung so nachziehen, dass Satzung, Protokolle und Nachweise sauber verwaltbar sind.
|
||||
- [x] Veranstaltungen: Vereinsveranstaltungen mit Fristen, Verantwortlichkeiten und Aufgabenverknuepfung als eigenes Modul umsetzen.
|
||||
- [x] Veranstaltungen: Terminarten, Statuswechsel und Nacharbeit fuer Planung, Einladung und Durchfuehrung trennen.
|
||||
- [x] Sponsoren: Sponsorenstamm mit Ansprechpartnern, Laufzeiten und Vertragsbezug als echte Pflegeansicht aufbauen.
|
||||
- [x] Sponsoren: Sponsoringanfragen, laufende Beziehungen und zugehoerige Rechnungen in einem Arbeitsfluss verbinden.
|
||||
- [x] Beiträge: Beitragssätze, Familienmodelle und Ermäßigungen als vereinfachte manuelle Regelbasis modellieren.
|
||||
- [x] Beiträge: Beitragsgruppen, Beitragszuordnung und Zahlungsbezug in Mitglieder- und Finanzsicht konsistent halten.
|
||||
- [x] Zahlungen: Offene Forderungen, Zahlungseingänge, Mahnstufen und Statuswechsel als tägliche Arbeitsliste weiter schärfen.
|
||||
- [x] Zahlungen: Kontobewegungen, Teilzahlungen und automatische Zuordnung zu Forderungen stabil mit dem Mitgliederbezug verbinden.
|
||||
- [x] Berichte: Vorstand, Finanzen, Archiv und Sponsoring mit echten Auswertungen und Exporten aus den vorhandenen Daten versorgen.
|
||||
- [x] Berichte: Wiederkehrende Reportsets, PDF-Ausgabe und Vorlagen fuer Standardauswertungen vorbereiten.
|
||||
|
||||
34
docs/club-communication-workflow.md
Normal file
34
docs/club-communication-workflow.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Kommunikation im Verein
|
||||
|
||||
Stand: 2026-06-22
|
||||
|
||||
## Empfängerlogik
|
||||
|
||||
- Einzelvorgänge adressieren genau ein Mitglied.
|
||||
- Gruppenvorgänge nutzen eine Verteilergruppe plus optionale Filter.
|
||||
- Rundschreiben arbeiten ohne feste Gruppe und filtern direkt auf dem Vorgang.
|
||||
|
||||
## Verfügbare Filter
|
||||
|
||||
- `Nur aktive Mitglieder`
|
||||
- `Nur mit E-Mail-Adresse`
|
||||
- `Nur mit aktivem SEPA-Mandat`
|
||||
- `Nur ohne aktives SEPA-Mandat`
|
||||
|
||||
Diese Filter werden beim Auflösen der Empfängerliste technisch berücksichtigt und nicht nur im Frontend angezeigt.
|
||||
|
||||
## Versandstatus
|
||||
|
||||
- `Ausstehend`: noch kein erfolgreicher Versand.
|
||||
- `Gesendet`: erfolgreich zugestellt.
|
||||
- `Fehlgeschlagen`: technischer oder fachlicher Fehler.
|
||||
- `Übersprungen`: bewusst nicht versendet, z. B. ohne E-Mail-Adresse.
|
||||
|
||||
Retry ist nur erlaubt, wenn der Fehler als retryfähig klassifiziert wurde, z. B. bei Zeitüberschreitungen oder temporären SMTP-Antworten.
|
||||
|
||||
## Vorlagen und Antworten
|
||||
|
||||
- Vorlagen enthalten Name, Kategorie, Betreff-Vorlage und Text-Vorlage.
|
||||
- `Platzhalter / Antworten dokumentieren` dient als technische Dokumentation für Variablen, Antwortvorgaben und Standardformulierungen pro Verein.
|
||||
- Beim Schreiben einer Nachricht kann eine Vorlage direkt in Betreff und Nachrichtentext übernommen werden.
|
||||
- Eingehende Rückmeldungen werden als Richtung `Eingehend` im Vorgang protokolliert.
|
||||
129
docs/club-outstanding-plan.md
Normal file
129
docs/club-outstanding-plan.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Club-Produkt: Ausbau und Absicherung
|
||||
|
||||
Stand: 2026-06-25
|
||||
|
||||
## Ziel
|
||||
|
||||
Die vorhandenen Club-Module sollen nicht mehr nur "vorhanden", sondern im Alltag stabil und nachvollziehbar nutzbar sein. Der Schwerpunkt liegt jetzt auf zwei Dingen:
|
||||
|
||||
- Ausbau der noch klar erkennbaren Restthemen.
|
||||
- Absicherung der bereits fertig wirkenden Arbeitsbereiche gegen Kantenfaelle, Rechteprobleme und unklare Zustande.
|
||||
|
||||
## Aktueller Fokus
|
||||
|
||||
- Kommunikation
|
||||
- Historie
|
||||
- Archiv
|
||||
- Restliche Club-UI-Absicherung in den Kernviews
|
||||
- Danach erst das `mein-tt.de`-Produkt inhaltlich weiter ausbauen
|
||||
|
||||
## Arbeitsreihenfolge
|
||||
|
||||
### Phase 1: Absicherung der bestehenden Club-Views
|
||||
|
||||
Status: in Arbeit
|
||||
|
||||
Ziel:
|
||||
- Keine haengenden Formulare bei Reload, Clubwechsel oder Auswahlwechsel.
|
||||
- Read-only-Zustaende sind sichtbar und verhindern keine Navigation.
|
||||
- Lade- und Fehlermeldungen sind konsistent und eindeutig.
|
||||
|
||||
Konkrete Teilaufgaben:
|
||||
- `ClubTasksView.vue`: Sicherstellen, dass `selectedTask` und `form` beim Clubwechsel, bei leerer Liste und nach Loesch-/Archivaktionen sauber getrennt werden.
|
||||
- `ClubCommunicationView.vue`: `selectedThread`, `threadForm`, `groupForm`, `templateForm` und `messageForm` beim Clubwechsel und nach Auswahlwechseln komplett zuruecksetzen.
|
||||
- `ClubAccountsView.vue`: `selectedAccount`, `selectedTransaction`, `form` und `transactionForm` in jedem Ruecksprung sauber bereinigen.
|
||||
- `ClubInvoicesView.vue`: `selectedInvoice`, `selectedParty`, `invoiceForm` und `partyForm` beim Clubwechsel und bei leeren Selektionen auf Default bringen.
|
||||
- Read-only-Hinweise auf jeder der vier Views vereinheitlichen, damit ein Nutzer mit fehlenden Rechten nicht erst in die Formulare klickt, um zu merken, dass keine Bearbeitung moeglich ist.
|
||||
- Ladezustände und Fehlerbanner auf eine einheitliche Form bringen, damit Reload und Fehlerfall nicht unterschiedlich wirken.
|
||||
- Ein kurzer Smoke-Check je View nach der Aenderung: Liste laden, Element auswaehlen, Auswahl loeschen, Club wechseln, erneut laden.
|
||||
|
||||
Gepruefte Kantenfaelle:
|
||||
- Club wird gewechselt, waehrend ein Detailformular offen ist.
|
||||
- Datenquelle liefert eine leere Liste und der zuletzt selektierte Datensatz existiert nicht mehr.
|
||||
- Nutzer hat nur Leserechte, soll aber trotzdem eine klare Orientierung haben.
|
||||
- Reload erfolgt waehrend ein Formular bereits mit Daten befuellt ist.
|
||||
|
||||
Fertig, wenn:
|
||||
- Die vier Kernviews ohne manuelle Nacharbeit zwischen Liste, Detail und Neu-Anlage wechseln.
|
||||
- Beim Clubwechsel keine Formularwerte aus dem vorherigen Club sichtbar bleiben.
|
||||
- Read-only-Nutzer die Bereiche verstehen, ohne in kaputte Aktionen zu laufen.
|
||||
|
||||
### Phase 2: Kommunikation produktiv absichern
|
||||
|
||||
Ziel:
|
||||
- Nachrichtenfluss nicht nur funktional, sondern praxisnah robust machen.
|
||||
|
||||
Arbeitspakete:
|
||||
- SMTP real testen, inklusive Zustellprotokoll und Fehlerfaelle.
|
||||
- Optional Reply-To pro Verein oder Kommunikationsvorlage sauber ergaenzen.
|
||||
- Dokumentanhaenge fuer Nachrichten und Vorlagen einfuehren.
|
||||
- Serienvorlagen und wiederkehrende Nachrichtentypen vorbereiten.
|
||||
|
||||
Fertig, wenn:
|
||||
- Eine Testzustellung je Verein reproduzierbar gelingt.
|
||||
- Fehlende SMTP-Konfiguration klar und frueh sichtbar wird.
|
||||
- Nachrichten mit Anhaengen und Vorlagen ohne Sonderlogik im Alltag einsetzbar sind.
|
||||
|
||||
### Phase 3: Historie und Archiv vertiefen
|
||||
|
||||
Ziel:
|
||||
- Vergaengliche Vorgange muessen spaeter besser auffindbar und nachvollziehbar sein.
|
||||
|
||||
Arbeitspakete:
|
||||
- Historie nach Modulen und Vorgangstypen filtern.
|
||||
- Historie exportierbar machen.
|
||||
- Historie mit Zielobjekten und Querverweisen versehen.
|
||||
- Archiv um weitere Entitaeten erweitern.
|
||||
- Archivsuche und Schnellfilter verbessern.
|
||||
|
||||
Fertig, wenn:
|
||||
- Vorstand oder Verwaltung einen Vorgang aus Historie oder Archiv ohne Umweg wiederfinden kann.
|
||||
- Wichtige Clubobjekte nicht nur archiviert, sondern auch wieder auffindbar und verlinkt sind.
|
||||
|
||||
### Phase 4: Restliche Club-UX verdichten
|
||||
|
||||
Ziel:
|
||||
- Das Dashboard und die Detailmodule sollen gleiche Sprache sprechen.
|
||||
|
||||
Arbeitspakete:
|
||||
- Dashboard-Schnellzugriffe weiter auf Tagesgeschaeft trimmen.
|
||||
- Verlinkungen zwischen Dashboard, Mitgliedern, Zahlungen, Kommunikation und Archiv schaerfen.
|
||||
- Kleine Inkonsistenzen in Statusworten, Akzentfarben und Listenlabels bereinigen.
|
||||
|
||||
Fertig, wenn:
|
||||
- Der Einstieg immer zur naechsten sinnvollen Aktion fuehrt.
|
||||
- Die wichtigsten Statuswerte nicht doppelt oder widerspruechlich gezeigt werden.
|
||||
|
||||
### Phase 5: Player-Produkt erst danach
|
||||
|
||||
Ziel:
|
||||
- `mein-tt.de` bekommt nur dann neue Inhalte, wenn die Club-Seite stabil ist.
|
||||
|
||||
Arbeitspakete:
|
||||
- Anforderungen fuer Spieleransichten separat sammeln.
|
||||
- Keine Club-spezifischen Workflows mehr in das Player-Produkt ziehen.
|
||||
- Neue Spielerfeatures nur gegen eigene Prioritaeten und nicht als Restverwertung der Club-Roadmap planen.
|
||||
|
||||
## Nicht als naechstes anfassen
|
||||
|
||||
- Generelles Beitrags- und Tarifsystem mit Familienlogik, Alterslogik und Gueltigkeitszeitrainen.
|
||||
- Weitere grosse Produktumbauten ohne klaren Nutzen fuer den Club-Alltag.
|
||||
- Zusätzliche Club-Module, solange die bestehenden Workflows noch nicht absicherungsfest sind.
|
||||
|
||||
## Konkrete naechste Tickets
|
||||
|
||||
- SMTP-Test fuer Kommunikation mit realer Zieladresse und dokumentiertem Ergebnis.
|
||||
- Dokumentanhaenge fuer Kommunikation und Vorlagen.
|
||||
- Historie: Filter und Export.
|
||||
- Archiv: weitere Objektklassen und Suche.
|
||||
- Club-UI-Smoke-Check fuer Aufgaben, Kommunikation, Konten und Rechnungen.
|
||||
|
||||
## Abhaengigkeiten
|
||||
|
||||
- SMTP-Test braucht eine real erreichbare Versandkonfiguration.
|
||||
- Historie-Export braucht klare Zielobjekt- und Filterdefinitionen.
|
||||
- Archiv-Erweiterungen sollten auf bereits vorhandene Dokument-, Rechnungs- und Beitragsdaten aufsetzen.
|
||||
|
||||
## Erwartetes Ergebnis
|
||||
|
||||
Nach dieser Runde sind die Club-Bereiche nicht nur vorhanden, sondern im Alltag kontrollierbar, nachvollziehbar und ausreichend robust fuer den produktiven Einsatz eines Vereins. Danach kann der Fokus auf neue inhaltliche Produktarbeit wechseln.
|
||||
31
docs/club-task-workflow-sources.md
Normal file
31
docs/club-task-workflow-sources.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Automatisierte Aufgabenquellen
|
||||
|
||||
Stand: 2026-06-22
|
||||
|
||||
## Stabile Quellen
|
||||
|
||||
- `club_requests`
|
||||
Basis: Kontakt-, Probetraining-, Mitgliedschafts- und Sponsoringanfragen.
|
||||
- `members`
|
||||
Basis: Mitgliedsdaten, fehlende Pflichtangaben, Statuswechsel.
|
||||
- `club_sepa_mandates`
|
||||
Basis: fehlende, widerrufene oder ablaufrelevante SEPA-Mandate.
|
||||
- `club_payment_claims`
|
||||
Basis: offene, teilweise bezahlte oder überfällige Beitragsforderungen.
|
||||
- `club_invoices`
|
||||
Basis: eingehende und ausgehende Rechnungen mit Fälligkeiten und Status.
|
||||
- `club_communication`
|
||||
Basis: fehlgeschlagene Zustellversuche mit retryfähigen Fehlern.
|
||||
- `calendar_events`
|
||||
Basis: Termine, Fristen und Vereinsveranstaltungen.
|
||||
|
||||
## Technisches Verhalten
|
||||
|
||||
- Jede automatische Aufgabe erhält `automation_source`, `automation_key` und `source_snapshot`.
|
||||
- `automation_key` dient zur Deduplizierung.
|
||||
- Ausgeblendete Vorschläge wirken vereinsweit, nicht nur pro Benutzer.
|
||||
- Aufgabenquellen werden im Aufgabenmodul sichtbar gemacht, damit nachvollziehbar bleibt, woher ein Vorschlag kommt.
|
||||
|
||||
## Ziel
|
||||
|
||||
Die Automatisierung soll keine Blackbox sein. Jeder Vorschlag muss auf einen stabilen Quelldatensatz zurückführbar und nach Änderungen reproduzierbar sein.
|
||||
80
frontend/MULTI_PRODUCT_PLAN.md
Normal file
80
frontend/MULTI_PRODUCT_PLAN.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Multi-Product Umbau fuer `mein-tt.de` und `tt-verein.de`
|
||||
|
||||
## Summary
|
||||
- Das Frontend wird von einer einheitlichen Vereins-App zu zwei klar getrennten Produkten auf gemeinsamer technischer Basis umgebaut:
|
||||
- `tt-verein.de` fuer Vereinsarbeit
|
||||
- `mein-tt.de` fuer einzelne Spieler
|
||||
- Im ersten Schritt wird nur die Architektur und Produkttrennung umgesetzt, nicht der volle Ausbau neuer Spieler- oder Finanzmodule.
|
||||
- Backend, Auth und Accounts bleiben vorerst gemeinsam; die Domain bestimmt Produktkontext, sichtbare Navigation, Standard-Startseite, zugelassene Routen und SEO.
|
||||
|
||||
## Implementation Changes
|
||||
- Einen zentralen Produktkontext einfuehren, der beim App-Start den Host auf ein Produkt mapped.
|
||||
- `tt-verein.de` => `club`
|
||||
- `mein-tt.de` => `player`
|
||||
- lokale Entwicklung zusaetzlich per Env-Override steuerbar, damit beide Produkte ohne DNS testbar bleiben
|
||||
- Den Produktkontext zentral bereitstellen, statt `window.location` spaeter verteilt in Views zu pruefen.
|
||||
- Store erweitert um `appProduct`, `appBrand`, `defaultHomeRoute`
|
||||
- kleine Konfigurationsquelle fuer Produkt-Metadaten, erlaubte Routen, SEO-Basisdaten und Navigationsdefinitionen
|
||||
|
||||
- Routing auf Produktfaehigkeit umstellen.
|
||||
- In `src/router.js` jede interne Route mit Produkt-Metadaten versehen, z. B. `products: ['club']`, `['player']`, `['club', 'player']`
|
||||
- Globaler Router-Guard blockiert direkte URL-Zugriffe auf fachlich unpassende Bereiche
|
||||
- Bei gesperrten Routen Umleitung auf produktpassende Startseite statt stiller Anzeige
|
||||
- Produktzuordnung im ersten Schritt:
|
||||
- `tt-verein.de`: bestehende Vereinsbereiche bleiben zugelassen, insbesondere Mitglieder, Tagebuch, Kalender, Freigaben, Statistiken, Turniere, Spielplaene, Vereinssettings, Teamverwaltung, Abrechnung
|
||||
- `mein-tt.de`: zunaechst nur persoenliche Bereiche plus Kalender
|
||||
- konkret freigegeben auf `mein-tt.de`: Startseite, Login/Register/Passwort-Flows, Kalender, persoenliche Einstellungen, MyTischtennis-/click-TT-Konto, Bestellungen, Impressum/Datenschutz/Konto loeschen
|
||||
- alle klar vereinszentrierten Bereiche auf `mein-tt.de` sperren, einschliesslich Club-Auswahl als Primaernavigation, Mitgliederverwaltung, Freigaben, Teamverwaltung, Vereinssettings, Billing, Turnier- und Spielplan-Arbeitsflaechen
|
||||
- falls einzelne heute technisch noch `currentClub` voraussetzen, bleiben sie auf `mein-tt.de` zunaechst ebenfalls gesperrt, bis sie produktneutral gemacht sind
|
||||
|
||||
- Navigation aus `App.vue` heraus in deklarative Produktnavigation ueberfuehren.
|
||||
- keine fest verdrahteten Link-Bloecke mehr pro Template-Abschnitt
|
||||
- Menue wird aus einer Konfigurationsliste gerendert: Label, Route, Icon, Permission-Regeln, Produktzuordnung
|
||||
- `tt-verein.de` behaelt Club-Selektor und vereinszentrierte Sidebar
|
||||
- `mein-tt.de` erhaelt eine reduzierte persoenliche Navigation ohne Club-Selektor als dominantes Element
|
||||
- Onboarding/Startverhalten trennen.
|
||||
- `tt-verein.de`: nach Login weiter club-zentriert; wenn kein Verein gewaehlt/verfuegbar, Club-Auswahl bzw. `createclub`
|
||||
- `mein-tt.de`: nach Login auf persoenliche Startseite; kein erzwungener Club-Schritt
|
||||
- bestehende Logik, die Navigation erst bei `selectedClub` sichtbar macht, wird fuer das Player-Produkt entkoppelt
|
||||
|
||||
- Public Surface und SEO pro Produkt trennen.
|
||||
- `src/utils/seo.js` nicht mehr auf `https://tt-tagebuch.de` fest verdrahten
|
||||
- produktabhaengige Canonical-URL, Seitentitel, OG-Daten und Standardbeschreibungen
|
||||
- `index.html` ohne feste Canonical auf alte Domain
|
||||
- oeffentliche Landingpages auf `tt-verein.de` vereinszentriert belassen
|
||||
- `mein-tt.de` bekommt eine eigene reduzierte oeffentliche Positionierung fuer Spieler, auch wenn die neuen Spielerfeatures fachlich erst spaeter kommen
|
||||
|
||||
## Public Interfaces / Config Changes
|
||||
- Neue zentrale Produktkonfiguration, z. B. in einer Datei wie `src/config/products.js`
|
||||
- Hostname -> Produkt
|
||||
- Produktname/Brand
|
||||
- Default-Route
|
||||
- erlaubte Routen
|
||||
- SEO-Basiswerte
|
||||
- Route-Meta wird erweitert um Produkt-Sichtbarkeit.
|
||||
- Optionale Env-Variablen fuer lokale und Deployment-seitige Steuerung:
|
||||
- Produkt-Override fuer lokale Entwicklung
|
||||
- optionale Host-/Canonical-Basis-URLs je Produkt
|
||||
|
||||
## Test Plan
|
||||
- Router-Guard:
|
||||
- `mein-tt.de` blockiert direkte Aufrufe von `/members`, `/billing`, `/club-settings`, `/team-management`
|
||||
- `tt-verein.de` laesst diese Routen bei Auth weiter zu
|
||||
- Navigation:
|
||||
- auf `mein-tt.de` erscheinen keine vereinszentrierten Menuepunkte
|
||||
- auf `tt-verein.de` bleibt die bestehende Vereinsnavigation erhalten
|
||||
- Onboarding:
|
||||
- Login auf `mein-tt.de` landet ohne Club-Zwang auf persoenlicher Startseite
|
||||
- Login auf `tt-verein.de` bleibt club-zentriert
|
||||
- SEO:
|
||||
- Canonical, Title und Description wechseln je Host korrekt
|
||||
- oeffentliche Vereins-Landingpages referenzieren `tt-verein.de`, nicht mehr `tt-tagebuch.de`
|
||||
- Regression:
|
||||
- bestehende Auth-Flows, Club-Wechsel und Permission-basierte Vereinsnavigation funktionieren auf `tt-verein.de` unveraendert weiter
|
||||
|
||||
## Assumptions
|
||||
- Gemeinsames Backend und gemeinsame Accounts bleiben im ersten Schritt bestehen.
|
||||
- Die Domain-Trennung ist fachlich hart: unpassende Bereiche werden nicht nur im Menue versteckt, sondern per Routing gesperrt.
|
||||
- `mein-tt.de` ist im ersten Schritt bewusst schmal und zeigt nur vorhandene persoenliche bzw. unkritische Bereiche plus Kalender.
|
||||
- Neue Spielerfunktionen wie individuelles Trainingsprogramm und Ziele sowie neue Vereinsmodule wie Budget/Finanzuebersichten/Rechnungen werden erst im naechsten Ausbau auf diese Architektur aufgesetzt.
|
||||
- Die bisherige Marke `tt-tagebuch.de` wird technisch nicht mehr als primaere oeffentliche Canonical-Basis behandelt, sobald die neuen Domains live sind.
|
||||
@@ -11,65 +11,48 @@
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
<title>Trainingstagebuch – Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere</title>
|
||||
<meta name="description" content="Trainingstagebuch: Vereinssoftware für Tischtennisvereine – Mitgliederverwaltung und Mitgliederprofile, Trainingsplanung, Trainingstagebuch, Turniere, Mannschaften, Statistiken, MyTischtennis-Anbindung." />
|
||||
<title>TT Verein und Mein TT</title>
|
||||
<meta name="description" content="Tischtennis-Software fuer Vereine und Spieler." />
|
||||
<meta name="robots" content="index,follow" />
|
||||
<link rel="canonical" href="https://tt-tagebuch.de/" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Trainingstagebuch" />
|
||||
<meta property="og:title" content="Trainingstagebuch – Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere" />
|
||||
<meta property="og:description" content="Vereinssoftware für Tischtennisvereine: Mitgliederverwaltung, Mitgliederprofile, Trainingsplanung, Turniere, Mannschaften, Statistiken, MyTischtennis-Anbindung." />
|
||||
<meta property="og:url" content="https://tt-tagebuch.de/" />
|
||||
<meta property="og:image" content="https://tt-tagebuch.de/android-chrome-512x512.png" />
|
||||
<meta property="og:site_name" content="TT Verein und Mein TT" />
|
||||
<meta property="og:title" content="TT Verein und Mein TT" />
|
||||
<meta property="og:description" content="Tischtennis-Software fuer Vereine und Spieler." />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Trainingstagebuch – Vereinsverwaltung für Tischtennis, Trainingsplanung & Turniere" />
|
||||
<meta name="twitter:description" content="Vereinssoftware für Tischtennisvereine: Mitgliederverwaltung, Mitgliederprofile, Trainingsplanung, Turniere, Mannschaften, Statistiken, MyTischtennis-Anbindung." />
|
||||
<meta name="twitter:image" content="https://tt-tagebuch.de/android-chrome-512x512.png" />
|
||||
<meta name="twitter:title" content="TT Verein und Mein TT" />
|
||||
<meta name="twitter:description" content="Tischtennis-Software fuer Vereine und Spieler." />
|
||||
|
||||
<!-- JSON-LD: Website + Organization -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "Trainingstagebuch",
|
||||
"url": "https://tt-tagebuch.de/",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": "https://tt-tagebuch.de/?q={search_term_string}",
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
"name": "TT Verein und Mein TT"
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "Trainingstagebuch",
|
||||
"name": "TT Verein und Mein TT",
|
||||
"applicationCategory": "SportsApplication",
|
||||
"operatingSystem": "Web",
|
||||
"description": "Umfassende Vereinsverwaltung mit Mitgliederverwaltung, Trainingsgruppen, Trainingszeiten, Trainingstagebuch, Turnierorganisation (intern, offen, offiziell), Team-Management, MyTischtennis-Integration, Statistiken und flexiblen Berechtigungssystemen – DSGVO‑konform und einfach zu bedienen.",
|
||||
"description": "Tischtennis-Software fuer Vereine und Spieler mit getrennten Produktoberflaechen.",
|
||||
"featureList": [
|
||||
"Mitgliederverwaltung & Mitgliederprofile",
|
||||
"Trainingsgruppen & Trainingszeiten",
|
||||
"Trainingstagebuch & Dokumentation",
|
||||
"Turniere (intern, offen, offiziell)",
|
||||
"Team-Management & Ligen",
|
||||
"MyTischtennis-Integration",
|
||||
"Statistiken & Auswertungen",
|
||||
"Rollen & Berechtigungssystem",
|
||||
"PDF-Export",
|
||||
"Aktivitätsprotokoll"
|
||||
"Vereinsverwaltung fuer Tischtennisvereine",
|
||||
"Persoenliche Spieleroberflaeche",
|
||||
"Kalender und Turnierbezug",
|
||||
"myTischtennis-Integration"
|
||||
],
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"url": "https://tt-tagebuch.de/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
|
||||
184
frontend/package-lock.json
generated
184
frontend/package-lock.json
generated
@@ -11,9 +11,11 @@
|
||||
"axios": "^1.7.3",
|
||||
"core-js": "^3.8.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"docx": "^9.7.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.0.0",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"socket.io-client": "^4.8.1",
|
||||
@@ -1708,6 +1710,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz",
|
||||
"integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
@@ -2120,6 +2131,12 @@
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2226,6 +2243,41 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/docx": {
|
||||
"version": "9.7.1",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
|
||||
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"hash.js": "^1.1.7",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.1.3",
|
||||
"xml": "^1.0.1",
|
||||
"xml-js": "^1.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/nanoid": {
|
||||
"version": "5.1.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.15.tgz",
|
||||
"integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
@@ -2904,6 +2956,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@@ -2960,6 +3022,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||
@@ -2998,7 +3066,6 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
@@ -3041,6 +3108,12 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -3108,6 +3181,24 @@
|
||||
"jspdf": "^2 || ^3 || ^4"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -3132,6 +3223,15 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -3216,6 +3316,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -3506,6 +3612,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@@ -3532,6 +3644,21 @@
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -3618,6 +3745,12 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.89.2",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.89.2.tgz",
|
||||
@@ -3680,6 +3813,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@@ -3693,6 +3835,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -3803,6 +3951,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -3953,6 +4110,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
@@ -3967,7 +4130,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
@@ -4288,6 +4450,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml-js": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"xml-js": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
"axios": "^1.7.3",
|
||||
"core-js": "^3.8.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"docx": "^9.7.1",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.0.0",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"jszip": "^3.10.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"socket.io-client": "^4.8.1",
|
||||
|
||||
464
frontend/sql/tt-verein-v1-schema.mysql.sql
Normal file
464
frontend/sql/tt-verein-v1-schema.mysql.sql
Normal file
@@ -0,0 +1,464 @@
|
||||
ALTER TABLE `clubs`
|
||||
ADD COLUMN IF NOT EXISTS `short_name` varchar(120) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `legal_name` varchar(255) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `club_number` varchar(64) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `email` varchar(255) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `phone` varchar(80) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `website` varchar(255) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `street` varchar(255) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `postal_code` varchar(24) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `city` varchar(120) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `country_code` varchar(2) NOT NULL DEFAULT 'DE',
|
||||
ADD COLUMN IF NOT EXISTS `chairperson_name` varchar(255) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `treasurer_name` varchar(255) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `youth_manager_name` varchar(255) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `creditor_identifier` varchar(64) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `billing_email` varchar(255) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `iban` varchar(34) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `bic` varchar(11) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `fee_rules` json NULL,
|
||||
ADD COLUMN IF NOT EXISTS `outgoing_invoice_prefix` varchar(24) NOT NULL DEFAULT 'RE',
|
||||
ADD COLUMN IF NOT EXISTS `outgoing_invoice_next_number` int NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS `incoming_invoice_prefix` varchar(24) NOT NULL DEFAULT 'EI',
|
||||
ADD COLUMN IF NOT EXISTS `incoming_invoice_next_number` int NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS `is_archived` tinyint(1) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `archived_at` datetime NULL;
|
||||
|
||||
ALTER TABLE `member`
|
||||
ADD COLUMN IF NOT EXISTS `member_number` varchar(64) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `membership_status` varchar(32) NOT NULL DEFAULT 'active',
|
||||
ADD COLUMN IF NOT EXISTS `membership_type` varchar(32) NOT NULL DEFAULT 'regular',
|
||||
ADD COLUMN IF NOT EXISTS `joined_on` date NULL,
|
||||
ADD COLUMN IF NOT EXISTS `left_on` date NULL,
|
||||
ADD COLUMN IF NOT EXISTS `contribution_group_code` varchar(64) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `needs_sepa_mandate` tinyint(1) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `sepa_mandate_reference` varchar(80) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `is_archived` tinyint(1) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `archived_at` datetime NULL,
|
||||
ADD COLUMN IF NOT EXISTS `archived_reason` text NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_requests` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`request_type` varchar(32) NOT NULL,
|
||||
`status` varchar(32) NOT NULL DEFAULT 'open',
|
||||
`workflow_stage` varchar(64) NULL,
|
||||
`priority` varchar(16) NOT NULL DEFAULT 'normal',
|
||||
`subject` varchar(255) NULL,
|
||||
`first_name` varchar(120) NULL,
|
||||
`last_name` varchar(120) NULL,
|
||||
`email` varchar(255) NULL,
|
||||
`phone` varchar(80) NULL,
|
||||
`message` text NULL,
|
||||
`source_system` varchar(64) NULL,
|
||||
`received_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`assigned_user_id` bigint NULL,
|
||||
`assigned_member_id` bigint NULL,
|
||||
`converted_member_id` bigint NULL,
|
||||
`closed_at` datetime NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_requests_club_status` (`club_id`, `status`, `request_type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_distribution_groups` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`description` text NULL,
|
||||
`group_type` varchar(32) NOT NULL DEFAULT 'custom',
|
||||
`is_system_group` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_distribution_groups_club` (`club_id`, `name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_distribution_group_members` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`group_id` bigint NOT NULL,
|
||||
`member_id` bigint NOT NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_club_distribution_group_member` (`group_id`, `member_id`),
|
||||
KEY `idx_club_distribution_group_members_member` (`member_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_communication_threads` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`thread_type` varchar(32) NOT NULL DEFAULT 'direct',
|
||||
`subject` varchar(255) NOT NULL,
|
||||
`status` varchar(32) NOT NULL DEFAULT 'draft',
|
||||
`created_by_user_id` bigint NULL,
|
||||
`distribution_group_id` bigint NULL,
|
||||
`recipient_member_id` bigint NULL,
|
||||
`scheduled_at` datetime NULL,
|
||||
`sent_at` datetime NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_communication_threads_club` (`club_id`, `status`, `thread_type`),
|
||||
KEY `idx_club_communication_threads_group` (`distribution_group_id`),
|
||||
KEY `idx_club_communication_threads_member` (`recipient_member_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_communication_messages` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`thread_id` bigint NOT NULL,
|
||||
`club_id` bigint NOT NULL,
|
||||
`message_type` varchar(32) NOT NULL DEFAULT 'message',
|
||||
`direction` varchar(32) NOT NULL DEFAULT 'outbound',
|
||||
`body` text NOT NULL,
|
||||
`created_by_user_id` bigint NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_communication_messages_thread` (`thread_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_communication_recipients` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`thread_id` bigint NOT NULL,
|
||||
`club_id` bigint NOT NULL,
|
||||
`member_id` bigint NULL,
|
||||
`recipient_name` varchar(255) NOT NULL,
|
||||
`email_snapshot` varchar(255) NULL,
|
||||
`delivery_status` varchar(32) NOT NULL DEFAULT 'pending',
|
||||
`delivered_at` datetime NULL,
|
||||
`last_attempt_at` datetime NULL,
|
||||
`attempt_count` int NOT NULL DEFAULT 0,
|
||||
`retryable` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`error_code` varchar(64) NULL,
|
||||
`transport_message_id` varchar(255) NULL,
|
||||
`error_message` text NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_communication_recipients_thread` (`thread_id`, `delivery_status`),
|
||||
KEY `idx_club_communication_recipients_member` (`member_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_communication_delivery_logs` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`thread_id` bigint NOT NULL,
|
||||
`recipient_id` bigint NULL,
|
||||
`created_by_user_id` bigint NULL,
|
||||
`status` varchar(32) NOT NULL DEFAULT 'failed',
|
||||
`attempt_no` int NOT NULL DEFAULT 1,
|
||||
`retryable` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`error_code` varchar(64) NULL,
|
||||
`error_message` text NULL,
|
||||
`transport_message_id` varchar(255) NULL,
|
||||
`transport_response` text NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_communication_delivery_logs_thread` (`thread_id`, `created_at`),
|
||||
KEY `idx_club_communication_delivery_logs_recipient` (`recipient_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `club_distribution_groups`
|
||||
ADD COLUMN IF NOT EXISTS `filter_definition` json NULL;
|
||||
|
||||
ALTER TABLE `club_communication_threads`
|
||||
ADD COLUMN IF NOT EXISTS `recipient_filters` json NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_communication_templates` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`name` varchar(160) NOT NULL,
|
||||
`category` varchar(64) NOT NULL DEFAULT 'general',
|
||||
`subject_template` varchar(255) NULL,
|
||||
`body_template` text NULL,
|
||||
`variables_hint` text NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_communication_templates_club` (`club_id`, `category`, `name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_account_transactions` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`account_id` bigint NOT NULL,
|
||||
`invoice_id` bigint NULL,
|
||||
`created_by_user_id` bigint NULL,
|
||||
`direction` varchar(16) NOT NULL DEFAULT 'credit',
|
||||
`booking_type` varchar(32) NOT NULL DEFAULT 'manual',
|
||||
`status` varchar(32) NOT NULL DEFAULT 'booked',
|
||||
`booking_date` date NOT NULL,
|
||||
`value_date` date NULL,
|
||||
`amount_cents` bigint NOT NULL DEFAULT 0,
|
||||
`currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
`reference` varchar(255) NULL,
|
||||
`notes` text NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_account_transactions_club_date` (`club_id`, `booking_date`, `status`),
|
||||
KEY `idx_club_account_transactions_account_date` (`account_id`, `booking_date`),
|
||||
KEY `idx_club_account_transactions_invoice` (`invoice_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_request_notes` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_request_id` bigint NOT NULL,
|
||||
`note_type` varchar(32) NOT NULL DEFAULT 'internal',
|
||||
`body` text NOT NULL,
|
||||
`created_by_user_id` bigint NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_request_notes_request` (`club_request_id`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_tasks` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`task_type` varchar(64) NULL,
|
||||
`description` text NULL,
|
||||
`status` varchar(32) NOT NULL DEFAULT 'open',
|
||||
`priority` varchar(16) NOT NULL DEFAULT 'normal',
|
||||
`due_at` datetime NULL,
|
||||
`remind_at` datetime NULL,
|
||||
`created_by_user_id` bigint NULL,
|
||||
`assigned_user_id` bigint NULL,
|
||||
`automation_source` varchar(64) NULL,
|
||||
`automation_key` varchar(255) NULL,
|
||||
`related_entity_type` varchar(32) NULL,
|
||||
`related_entity_id` bigint NULL,
|
||||
`completed_at` datetime NULL,
|
||||
`archived_at` datetime NULL,
|
||||
`source_snapshot` json NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_tasks_club_status_due` (`club_id`, `status`, `due_at`),
|
||||
UNIQUE KEY `uq_club_tasks_automation_key` (`club_id`, `automation_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_task_suppressions` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`automation_key` varchar(255) NOT NULL,
|
||||
`suppression_token` varchar(255) NOT NULL,
|
||||
`dismissed_by_user_id` bigint NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_club_task_suppressions_key` (`club_id`, `automation_key`),
|
||||
KEY `idx_club_task_suppressions_lookup` (`club_id`, `automation_key`, `suppression_token`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_roles` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`role_key` varchar(64) NOT NULL,
|
||||
`name` varchar(120) NOT NULL,
|
||||
`description` text NULL,
|
||||
`permissions` json NOT NULL,
|
||||
`is_system_role` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`sort_order` int NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_club_roles_key` (`club_id`, `role_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_user_roles` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`user_id` bigint NOT NULL,
|
||||
`club_role_id` bigint NOT NULL,
|
||||
`is_primary` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_club_user_roles_assignment` (`club_id`, `user_id`, `club_role_id`),
|
||||
KEY `idx_club_user_roles_user` (`club_id`, `user_id`),
|
||||
KEY `idx_club_user_roles_role` (`club_role_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_sepa_mandates` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`member_id` bigint NULL,
|
||||
`debtor_name` varchar(255) NOT NULL,
|
||||
`iban` varchar(34) NOT NULL,
|
||||
`bic` varchar(11) NULL,
|
||||
`mandate_reference` varchar(80) NOT NULL,
|
||||
`signed_on` date NULL,
|
||||
`valid_from` date NULL,
|
||||
`revoked_at` datetime NULL,
|
||||
`status` varchar(32) NOT NULL DEFAULT 'active',
|
||||
`history_note` text NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_sepa_mandates_club_member` (`club_id`, `member_id`, `status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_payment_claims` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`member_id` bigint NULL,
|
||||
`fee_rule_id` bigint NULL,
|
||||
`claim_type` varchar(32) NOT NULL DEFAULT 'membership_fee',
|
||||
`status` varchar(32) NOT NULL DEFAULT 'open',
|
||||
`due_on` date NOT NULL,
|
||||
`amount_cents` bigint NOT NULL,
|
||||
`currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
`reminder_level` int NOT NULL DEFAULT 0,
|
||||
`last_reminder_at` datetime NULL,
|
||||
`notes` text NULL,
|
||||
`settled_at` datetime NULL,
|
||||
`archived_at` datetime NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_payment_claims_club_status_due` (`club_id`, `status`, `due_on`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_accounts` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`name` varchar(160) NOT NULL,
|
||||
`account_holder` varchar(255) NULL,
|
||||
`bank_name` varchar(160) NULL,
|
||||
`iban` varchar(34) NULL,
|
||||
`bic` varchar(11) NULL,
|
||||
`account_type` varchar(16) NOT NULL DEFAULT 'bank',
|
||||
`usage_type` varchar(32) NOT NULL DEFAULT 'general',
|
||||
`currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
`allow_sepa_collections` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`allow_outgoing_payments` tinyint(1) NOT NULL DEFAULT 1,
|
||||
`is_default` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`status` varchar(16) NOT NULL DEFAULT 'active',
|
||||
`notes` text NULL,
|
||||
`sort_order` int NOT NULL DEFAULT 0,
|
||||
`archived_at` datetime NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_accounts_club_status` (`club_id`, `status`, `account_type`),
|
||||
KEY `idx_club_accounts_default` (`club_id`, `is_default`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_invoice_parties` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`party_type` varchar(32) NOT NULL DEFAULT 'customer',
|
||||
`status` varchar(32) NOT NULL DEFAULT 'active',
|
||||
`name` varchar(255) NOT NULL,
|
||||
`contract_reference` varchar(120) NULL,
|
||||
`valid_from` date NULL,
|
||||
`valid_to` date NULL,
|
||||
`contact_name` varchar(255) NULL,
|
||||
`email` varchar(255) NULL,
|
||||
`phone` varchar(80) NULL,
|
||||
`street` varchar(255) NULL,
|
||||
`postal_code` varchar(24) NULL,
|
||||
`city` varchar(120) NULL,
|
||||
`country_code` varchar(2) NOT NULL DEFAULT 'DE',
|
||||
`iban` varchar(34) NULL,
|
||||
`bic` varchar(11) NULL,
|
||||
`tax_identifier` varchar(64) NULL,
|
||||
`notes` text NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_invoice_parties_club_type` (`club_id`, `party_type`),
|
||||
KEY `idx_club_invoice_parties_club_status` (`club_id`, `status`),
|
||||
KEY `idx_club_invoice_parties_club_valid_from` (`club_id`, `valid_from`),
|
||||
KEY `idx_club_invoice_parties_club_valid_to` (`club_id`, `valid_to`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_invoices` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`invoice_direction` varchar(16) NOT NULL,
|
||||
`invoice_type` varchar(32) NOT NULL,
|
||||
`status` varchar(32) NOT NULL DEFAULT 'draft',
|
||||
`invoice_number` varchar(64) NULL,
|
||||
`external_reference` varchar(255) NULL,
|
||||
`party_id` bigint NULL,
|
||||
`account_id` bigint NULL,
|
||||
`issued_on` date NULL,
|
||||
`due_on` date NULL,
|
||||
`paid_on` date NULL,
|
||||
`net_amount_cents` bigint NOT NULL DEFAULT 0,
|
||||
`tax_amount_cents` bigint NOT NULL DEFAULT 0,
|
||||
`gross_amount_cents` bigint NOT NULL DEFAULT 0,
|
||||
`currency_code` varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
`description` text NULL,
|
||||
`document_id` bigint NULL,
|
||||
`created_by_user_id` bigint NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`archived_at` datetime NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_invoices_club_direction_status` (`club_id`, `invoice_direction`, `status`, `due_on`),
|
||||
UNIQUE KEY `uq_club_invoices_number` (`club_id`, `invoice_number`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
SET @club_invoices_number_index_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'club_invoices'
|
||||
AND index_name = 'uq_club_invoices_number'
|
||||
);
|
||||
SET @club_invoices_number_index_sql := IF(
|
||||
@club_invoices_number_index_exists = 0,
|
||||
'ALTER TABLE `club_invoices` ADD UNIQUE KEY `uq_club_invoices_number` (`club_id`, `invoice_number`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE club_invoices_number_index_stmt FROM @club_invoices_number_index_sql;
|
||||
EXECUTE club_invoices_number_index_stmt;
|
||||
DEALLOCATE PREPARE club_invoices_number_index_stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_invoice_items` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`invoice_id` bigint NOT NULL,
|
||||
`line_no` int NOT NULL DEFAULT 1,
|
||||
`description` text NOT NULL,
|
||||
`quantity` decimal(12,2) NOT NULL DEFAULT 1.00,
|
||||
`unit_price_cents` bigint NOT NULL DEFAULT 0,
|
||||
`tax_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
|
||||
`total_cents` bigint NOT NULL DEFAULT 0,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_club_invoice_items_line` (`invoice_id`, `line_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
ALTER TABLE `club_communication_recipients`
|
||||
ADD COLUMN IF NOT EXISTS `last_attempt_at` datetime NULL,
|
||||
ADD COLUMN IF NOT EXISTS `attempt_count` int NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `retryable` tinyint(1) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS `error_code` varchar(64) NULL,
|
||||
ADD COLUMN IF NOT EXISTS `transport_message_id` varchar(255) NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `club_communication_delivery_logs` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`club_id` bigint NOT NULL,
|
||||
`thread_id` bigint NOT NULL,
|
||||
`recipient_id` bigint NULL,
|
||||
`created_by_user_id` bigint NULL,
|
||||
`status` varchar(32) NOT NULL DEFAULT 'failed',
|
||||
`attempt_no` int NOT NULL DEFAULT 1,
|
||||
`retryable` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`error_code` varchar(64) NULL,
|
||||
`error_message` text NULL,
|
||||
`transport_message_id` varchar(255) NULL,
|
||||
`transport_response` text NULL,
|
||||
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_club_communication_delivery_logs_thread` (`thread_id`, `created_at`),
|
||||
KEY `idx_club_communication_delivery_logs_recipient` (`recipient_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
853
frontend/sql/tt-verein-v1-schema.sql
Normal file
853
frontend/sql/tt-verein-v1-schema.sql
Normal file
@@ -0,0 +1,853 @@
|
||||
BEGIN;
|
||||
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
|
||||
ALTER TABLE IF EXISTS clubs
|
||||
ADD COLUMN IF NOT EXISTS short_name varchar(120),
|
||||
ADD COLUMN IF NOT EXISTS legal_name varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS club_number varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS email varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS phone varchar(80),
|
||||
ADD COLUMN IF NOT EXISTS website varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS street varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS postal_code varchar(24),
|
||||
ADD COLUMN IF NOT EXISTS city varchar(120),
|
||||
ADD COLUMN IF NOT EXISTS country_code varchar(2) DEFAULT 'DE',
|
||||
ADD COLUMN IF NOT EXISTS chairperson_name varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS treasurer_name varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS youth_manager_name varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS creditor_identifier varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS billing_email varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS iban varchar(34),
|
||||
ADD COLUMN IF NOT EXISTS bic varchar(11),
|
||||
ADD COLUMN IF NOT EXISTS fee_rules jsonb,
|
||||
ADD COLUMN IF NOT EXISTS outgoing_invoice_prefix varchar(24) NOT NULL DEFAULT 'RE',
|
||||
ADD COLUMN IF NOT EXISTS outgoing_invoice_next_number integer NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS incoming_invoice_prefix varchar(24) NOT NULL DEFAULT 'EI',
|
||||
ADD COLUMN IF NOT EXISTS incoming_invoice_next_number integer NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS archived_at timestamptz;
|
||||
|
||||
ALTER TABLE IF EXISTS member
|
||||
ADD COLUMN IF NOT EXISTS member_number varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS membership_status varchar(32) NOT NULL DEFAULT 'active',
|
||||
ADD COLUMN IF NOT EXISTS membership_type varchar(32) NOT NULL DEFAULT 'regular',
|
||||
ADD COLUMN IF NOT EXISTS joined_on date,
|
||||
ADD COLUMN IF NOT EXISTS left_on date,
|
||||
ADD COLUMN IF NOT EXISTS contribution_group_code varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS needs_sepa_mandate boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS sepa_mandate_reference varchar(80),
|
||||
ADD COLUMN IF NOT EXISTS is_archived boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS archived_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS archived_reason text;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_requests (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
request_type varchar(32) NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'open',
|
||||
workflow_stage varchar(64),
|
||||
priority varchar(16) NOT NULL DEFAULT 'normal',
|
||||
source_system varchar(64),
|
||||
source_reference varchar(255),
|
||||
subject varchar(255),
|
||||
first_name varchar(120),
|
||||
last_name varchar(120),
|
||||
email varchar(255),
|
||||
phone varchar(80),
|
||||
birthdate date,
|
||||
message text,
|
||||
requested_membership_type varchar(32),
|
||||
assigned_user_id bigint,
|
||||
assigned_member_id bigint,
|
||||
converted_member_id bigint,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
closed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_distribution_groups (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
name varchar(255) NOT NULL,
|
||||
description text,
|
||||
group_type varchar(32) NOT NULL DEFAULT 'custom',
|
||||
is_system_group boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_distribution_groups_club
|
||||
ON club_distribution_groups (club_id, name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_distribution_group_members (
|
||||
id bigserial PRIMARY KEY,
|
||||
group_id bigint NOT NULL,
|
||||
member_id bigint NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (group_id, member_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_distribution_group_members_member
|
||||
ON club_distribution_group_members (member_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_communication_threads (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
thread_type varchar(32) NOT NULL DEFAULT 'direct',
|
||||
subject varchar(255) NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'draft',
|
||||
created_by_user_id bigint,
|
||||
distribution_group_id bigint,
|
||||
recipient_member_id bigint,
|
||||
scheduled_at timestamptz,
|
||||
sent_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_communication_threads_club
|
||||
ON club_communication_threads (club_id, status, thread_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_communication_messages (
|
||||
id bigserial PRIMARY KEY,
|
||||
thread_id bigint NOT NULL,
|
||||
club_id bigint NOT NULL,
|
||||
message_type varchar(32) NOT NULL DEFAULT 'message',
|
||||
direction varchar(32) NOT NULL DEFAULT 'outbound',
|
||||
body text NOT NULL,
|
||||
created_by_user_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_communication_messages_thread
|
||||
ON club_communication_messages (thread_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_communication_recipients (
|
||||
id bigserial PRIMARY KEY,
|
||||
thread_id bigint NOT NULL,
|
||||
club_id bigint NOT NULL,
|
||||
member_id bigint,
|
||||
recipient_name varchar(255) NOT NULL,
|
||||
email_snapshot varchar(255),
|
||||
delivery_status varchar(32) NOT NULL DEFAULT 'pending',
|
||||
delivered_at timestamptz,
|
||||
last_attempt_at timestamptz,
|
||||
attempt_count integer NOT NULL DEFAULT 0,
|
||||
retryable boolean NOT NULL DEFAULT false,
|
||||
error_code varchar(64),
|
||||
transport_message_id varchar(255),
|
||||
error_message text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_communication_recipients_thread
|
||||
ON club_communication_recipients (thread_id, delivery_status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_communication_delivery_logs (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
thread_id bigint NOT NULL,
|
||||
recipient_id bigint,
|
||||
created_by_user_id bigint,
|
||||
status varchar(32) NOT NULL DEFAULT 'failed',
|
||||
attempt_no integer NOT NULL DEFAULT 1,
|
||||
retryable boolean NOT NULL DEFAULT false,
|
||||
error_code varchar(64),
|
||||
error_message text,
|
||||
transport_message_id varchar(255),
|
||||
transport_response text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_thread
|
||||
ON club_communication_delivery_logs (thread_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_recipient
|
||||
ON club_communication_delivery_logs (recipient_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_requests_club_status
|
||||
ON club_requests (club_id, status, request_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_request_notes (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_request_id bigint NOT NULL,
|
||||
note_type varchar(32) NOT NULL DEFAULT 'internal',
|
||||
body text NOT NULL,
|
||||
created_by_user_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_request_notes_request
|
||||
ON club_request_notes (club_request_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_distribution_groups (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
name varchar(160) NOT NULL,
|
||||
description text,
|
||||
group_type varchar(32) NOT NULL DEFAULT 'manual',
|
||||
is_system_group boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_distribution_groups_name
|
||||
ON club_distribution_groups (club_id, lower(name));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_distribution_group_members (
|
||||
id bigserial PRIMARY KEY,
|
||||
distribution_group_id bigint NOT NULL,
|
||||
member_id bigint,
|
||||
user_id bigint,
|
||||
email varchar(255),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_distribution_group_members_group
|
||||
ON club_distribution_group_members (distribution_group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_communication_threads (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
thread_type varchar(32) NOT NULL,
|
||||
subject varchar(255) NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'draft',
|
||||
created_by_user_id bigint,
|
||||
scheduled_at timestamptz,
|
||||
sent_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_communication_threads_club_status
|
||||
ON club_communication_threads (club_id, status, thread_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_communication_recipients (
|
||||
id bigserial PRIMARY KEY,
|
||||
thread_id bigint NOT NULL,
|
||||
member_id bigint,
|
||||
user_id bigint,
|
||||
distribution_group_id bigint,
|
||||
email varchar(255),
|
||||
delivery_status varchar(32) NOT NULL DEFAULT 'pending',
|
||||
delivered_at timestamptz,
|
||||
read_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_communication_recipients_thread
|
||||
ON club_communication_recipients (thread_id, delivery_status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_communication_messages (
|
||||
id bigserial PRIMARY KEY,
|
||||
thread_id bigint NOT NULL,
|
||||
sender_user_id bigint,
|
||||
sender_member_id bigint,
|
||||
body text NOT NULL,
|
||||
body_format varchar(16) NOT NULL DEFAULT 'plain',
|
||||
sent_at timestamptz NOT NULL DEFAULT now(),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_communication_messages_thread
|
||||
ON club_communication_messages (thread_id, sent_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_events (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
event_type varchar(32) NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'planned',
|
||||
title varchar(255) NOT NULL,
|
||||
description text,
|
||||
location varchar(255),
|
||||
starts_at timestamptz NOT NULL,
|
||||
ends_at timestamptz,
|
||||
registration_deadline timestamptz,
|
||||
organizer_user_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
archived_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_events_club_starts
|
||||
ON club_events (club_id, starts_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_event_participants (
|
||||
id bigserial PRIMARY KEY,
|
||||
event_id bigint NOT NULL,
|
||||
member_id bigint,
|
||||
user_id bigint,
|
||||
role_code varchar(32),
|
||||
participation_status varchar(32) NOT NULL DEFAULT 'planned',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_event_participants_event
|
||||
ON club_event_participants (event_id, participation_status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_documents (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
document_type varchar(32) NOT NULL,
|
||||
title varchar(255) NOT NULL,
|
||||
description text,
|
||||
status varchar(32) NOT NULL DEFAULT 'active',
|
||||
visibility_scope varchar(32) NOT NULL DEFAULT 'board',
|
||||
owner_user_id bigint,
|
||||
current_version_no integer NOT NULL DEFAULT 1,
|
||||
archived_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_documents_club_type
|
||||
ON club_documents (club_id, document_type, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_document_versions (
|
||||
id bigserial PRIMARY KEY,
|
||||
document_id bigint NOT NULL,
|
||||
version_no integer NOT NULL,
|
||||
file_name varchar(255) NOT NULL,
|
||||
storage_path varchar(500) NOT NULL,
|
||||
mime_type varchar(120),
|
||||
file_size_bytes bigint,
|
||||
checksum_sha256 varchar(64),
|
||||
uploaded_by_user_id bigint,
|
||||
uploaded_at timestamptz NOT NULL DEFAULT now(),
|
||||
change_note text
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_document_versions_document_version
|
||||
ON club_document_versions (document_id, version_no);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_document_links (
|
||||
id bigserial PRIMARY KEY,
|
||||
document_id bigint NOT NULL,
|
||||
linked_entity_type varchar(32) NOT NULL,
|
||||
linked_entity_id bigint NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_document_links_entity
|
||||
ON club_document_links (linked_entity_type, linked_entity_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_tasks (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
title varchar(255) NOT NULL,
|
||||
task_type varchar(64),
|
||||
description text,
|
||||
status varchar(32) NOT NULL DEFAULT 'open',
|
||||
priority varchar(16) NOT NULL DEFAULT 'normal',
|
||||
due_at timestamptz,
|
||||
remind_at timestamptz,
|
||||
created_by_user_id bigint,
|
||||
assigned_user_id bigint,
|
||||
automation_source varchar(64),
|
||||
automation_key varchar(255),
|
||||
related_entity_type varchar(32),
|
||||
related_entity_id bigint,
|
||||
source_snapshot jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
completed_at timestamptz,
|
||||
archived_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_tasks_club_status_due
|
||||
ON club_tasks (club_id, status, due_at);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_tasks_automation_key
|
||||
ON club_tasks (club_id, automation_key)
|
||||
WHERE automation_key IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_task_suppressions (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
automation_key varchar(255) NOT NULL,
|
||||
suppression_token varchar(255) NOT NULL,
|
||||
dismissed_by_user_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_task_suppressions_key
|
||||
ON club_task_suppressions (club_id, automation_key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_task_suppressions_lookup
|
||||
ON club_task_suppressions (club_id, automation_key, suppression_token);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_sponsors (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
name varchar(255) NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'lead',
|
||||
website varchar(255),
|
||||
email varchar(255),
|
||||
phone varchar(80),
|
||||
street varchar(255),
|
||||
postal_code varchar(24),
|
||||
city varchar(120),
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
archived_at timestamptz
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_sponsors_name
|
||||
ON club_sponsors (club_id, lower(name));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_sponsor_contacts (
|
||||
id bigserial PRIMARY KEY,
|
||||
sponsor_id bigint NOT NULL,
|
||||
first_name varchar(120),
|
||||
last_name varchar(120),
|
||||
role_title varchar(120),
|
||||
email varchar(255),
|
||||
phone varchar(80),
|
||||
is_primary boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_sponsor_contacts_sponsor
|
||||
ON club_sponsor_contacts (sponsor_id, is_primary DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_sponsor_contracts (
|
||||
id bigserial PRIMARY KEY,
|
||||
sponsor_id bigint NOT NULL,
|
||||
title varchar(255) NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'draft',
|
||||
starts_on date,
|
||||
ends_on date,
|
||||
annual_amount_cents bigint,
|
||||
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
notes text,
|
||||
document_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_sponsor_contracts_sponsor
|
||||
ON club_sponsor_contracts (sponsor_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_fee_rules (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
code varchar(64) NOT NULL,
|
||||
name varchar(255) NOT NULL,
|
||||
category varchar(32) NOT NULL DEFAULT 'membership',
|
||||
billing_cycle varchar(16) NOT NULL DEFAULT 'monthly',
|
||||
base_amount_cents bigint NOT NULL,
|
||||
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
age_from integer,
|
||||
age_to integer,
|
||||
is_family_rule boolean NOT NULL DEFAULT false,
|
||||
is_reduction_rule boolean NOT NULL DEFAULT false,
|
||||
valid_from date NOT NULL DEFAULT CURRENT_DATE,
|
||||
valid_to date,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_fee_rules_code
|
||||
ON club_fee_rules (club_id, code);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_fee_rule_assignments (
|
||||
id bigserial PRIMARY KEY,
|
||||
fee_rule_id bigint NOT NULL,
|
||||
member_id bigint NOT NULL,
|
||||
valid_from date NOT NULL DEFAULT CURRENT_DATE,
|
||||
valid_to date,
|
||||
discount_percent numeric(5,2),
|
||||
fixed_amount_cents bigint,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_fee_rule_assignments_member
|
||||
ON club_fee_rule_assignments (member_id, valid_from DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_payment_accounts (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
account_name varchar(255) NOT NULL,
|
||||
account_type varchar(32) NOT NULL DEFAULT 'bank',
|
||||
iban varchar(34),
|
||||
bic varchar(11),
|
||||
bank_name varchar(255),
|
||||
account_holder varchar(255),
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_payment_accounts_club
|
||||
ON club_payment_accounts (club_id, is_default DESC, is_active DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_sepa_mandates (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
member_id bigint,
|
||||
debtor_name varchar(255) NOT NULL,
|
||||
iban varchar(34) NOT NULL,
|
||||
bic varchar(11),
|
||||
mandate_reference varchar(80) NOT NULL,
|
||||
signed_on date,
|
||||
valid_from date,
|
||||
revoked_at timestamptz,
|
||||
status varchar(32) NOT NULL DEFAULT 'active',
|
||||
history_note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_sepa_mandates_reference
|
||||
ON club_sepa_mandates (club_id, mandate_reference);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_payment_claims (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
member_id bigint,
|
||||
fee_rule_id bigint,
|
||||
claim_type varchar(32) NOT NULL DEFAULT 'membership_fee',
|
||||
status varchar(32) NOT NULL DEFAULT 'open',
|
||||
due_on date NOT NULL,
|
||||
amount_cents bigint NOT NULL,
|
||||
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
reminder_level integer NOT NULL DEFAULT 0,
|
||||
last_reminder_at timestamptz,
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
settled_at timestamptz,
|
||||
archived_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_payment_claims_club_status_due
|
||||
ON club_payment_claims (club_id, status, due_on);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_accounts (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
name varchar(160) NOT NULL,
|
||||
account_holder varchar(255),
|
||||
bank_name varchar(160),
|
||||
iban varchar(34),
|
||||
bic varchar(11),
|
||||
account_type varchar(16) NOT NULL DEFAULT 'bank',
|
||||
usage_type varchar(32) NOT NULL DEFAULT 'general',
|
||||
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
allow_sepa_collections boolean NOT NULL DEFAULT false,
|
||||
allow_outgoing_payments boolean NOT NULL DEFAULT true,
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
status varchar(16) NOT NULL DEFAULT 'active',
|
||||
notes text,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
archived_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_accounts_club_status
|
||||
ON club_accounts (club_id, status, account_type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_accounts_default
|
||||
ON club_accounts (club_id, is_default);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_payment_entries (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
payment_claim_id bigint,
|
||||
payment_account_id bigint,
|
||||
entry_type varchar(32) NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'booked',
|
||||
booked_on date NOT NULL DEFAULT CURRENT_DATE,
|
||||
amount_cents bigint NOT NULL,
|
||||
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
reference_text varchar(255),
|
||||
external_reference varchar(255),
|
||||
created_by_user_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_payment_entries_club_booked
|
||||
ON club_payment_entries (club_id, booked_on DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_invoice_parties (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
party_type varchar(32) NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'active',
|
||||
name varchar(255) NOT NULL,
|
||||
contract_reference varchar(120),
|
||||
valid_from date,
|
||||
valid_to date,
|
||||
contact_name varchar(255),
|
||||
email varchar(255),
|
||||
phone varchar(80),
|
||||
street varchar(255),
|
||||
postal_code varchar(24),
|
||||
city varchar(120),
|
||||
country_code varchar(2) DEFAULT 'DE',
|
||||
iban varchar(34),
|
||||
bic varchar(11),
|
||||
tax_identifier varchar(64),
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_type
|
||||
ON club_invoice_parties (club_id, party_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_status
|
||||
ON club_invoice_parties (club_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_valid_from
|
||||
ON club_invoice_parties (club_id, valid_from);
|
||||
CREATE INDEX IF NOT EXISTS idx_club_invoice_parties_club_valid_to
|
||||
ON club_invoice_parties (club_id, valid_to);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_invoices (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
invoice_direction varchar(16) NOT NULL,
|
||||
invoice_type varchar(32) NOT NULL,
|
||||
status varchar(32) NOT NULL DEFAULT 'draft',
|
||||
invoice_number varchar(64),
|
||||
external_reference varchar(255),
|
||||
party_id bigint,
|
||||
account_id bigint,
|
||||
issued_on date,
|
||||
due_on date,
|
||||
paid_on date,
|
||||
net_amount_cents bigint NOT NULL DEFAULT 0,
|
||||
tax_amount_cents bigint NOT NULL DEFAULT 0,
|
||||
gross_amount_cents bigint NOT NULL DEFAULT 0,
|
||||
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
description text,
|
||||
document_id bigint,
|
||||
created_by_user_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
archived_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_invoices_club_direction_status
|
||||
ON club_invoices (club_id, invoice_direction, status, due_on);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_invoices_number
|
||||
ON club_invoices (club_id, invoice_number)
|
||||
WHERE invoice_number IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_invoice_items (
|
||||
id bigserial PRIMARY KEY,
|
||||
invoice_id bigint NOT NULL,
|
||||
line_no integer NOT NULL DEFAULT 1,
|
||||
description text NOT NULL,
|
||||
quantity numeric(12,2) NOT NULL DEFAULT 1,
|
||||
unit_price_cents bigint NOT NULL DEFAULT 0,
|
||||
tax_rate numeric(5,2) NOT NULL DEFAULT 0,
|
||||
total_cents bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_club_invoice_items_line
|
||||
ON club_invoice_items (invoice_id, line_no);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_history_entries (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
entity_type varchar(32) NOT NULL,
|
||||
entity_id bigint NOT NULL,
|
||||
action_type varchar(32) NOT NULL,
|
||||
actor_user_id bigint,
|
||||
actor_member_id bigint,
|
||||
old_value_json jsonb,
|
||||
new_value_json jsonb,
|
||||
summary text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_history_entries_club_entity
|
||||
ON club_history_entries (club_id, entity_type, entity_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_user_roles (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
user_id bigint,
|
||||
member_id bigint,
|
||||
role_code varchar(32) NOT NULL,
|
||||
valid_from date NOT NULL DEFAULT CURRENT_DATE,
|
||||
valid_to date,
|
||||
is_primary boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_user_roles_club_user
|
||||
ON club_user_roles (club_id, user_id, role_code);
|
||||
|
||||
ALTER TABLE club_tasks
|
||||
ADD COLUMN IF NOT EXISTS task_type varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS automation_source varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS automation_key varchar(255),
|
||||
ADD COLUMN IF NOT EXISTS source_snapshot jsonb;
|
||||
|
||||
ALTER TABLE club_requests
|
||||
ADD COLUMN IF NOT EXISTS workflow_stage varchar(64);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_requests_type') THEN
|
||||
ALTER TABLE club_requests
|
||||
ADD CONSTRAINT chk_club_requests_type
|
||||
CHECK (request_type IN ('contact', 'trial_training', 'membership', 'sponsoring'));
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_requests_status') THEN
|
||||
ALTER TABLE club_requests
|
||||
ADD CONSTRAINT chk_club_requests_status
|
||||
CHECK (status IN ('open', 'in_progress', 'waiting', 'converted', 'rejected', 'archived'));
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_events_type') THEN
|
||||
ALTER TABLE club_events
|
||||
ADD CONSTRAINT chk_club_events_type
|
||||
CHECK (event_type IN ('training', 'match', 'club_event', 'meeting', 'deadline'));
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_tasks_status') THEN
|
||||
ALTER TABLE club_tasks
|
||||
ADD CONSTRAINT chk_club_tasks_status
|
||||
CHECK (status IN ('open', 'in_progress', 'waiting', 'done', 'cancelled', 'archived'));
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_fee_rules_cycle') THEN
|
||||
ALTER TABLE club_fee_rules
|
||||
ADD CONSTRAINT chk_club_fee_rules_cycle
|
||||
CHECK (billing_cycle IN ('monthly', 'quarterly', 'half_yearly', 'yearly', 'one_time'));
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_payment_claims_status') THEN
|
||||
ALTER TABLE club_payment_claims
|
||||
ADD CONSTRAINT chk_club_payment_claims_status
|
||||
CHECK (status IN ('open', 'partially_paid', 'paid', 'written_off', 'cancelled'));
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_accounts_type') THEN
|
||||
ALTER TABLE club_accounts
|
||||
ADD CONSTRAINT chk_club_accounts_type
|
||||
CHECK (account_type IN ('bank', 'cash', 'virtual'));
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_accounts_usage') THEN
|
||||
ALTER TABLE club_accounts
|
||||
ADD CONSTRAINT chk_club_accounts_usage
|
||||
CHECK (usage_type IN ('general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash'));
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_accounts_status') THEN
|
||||
ALTER TABLE club_accounts
|
||||
ADD CONSTRAINT chk_club_accounts_status
|
||||
CHECK (status IN ('active', 'inactive', 'archived'));
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_club_invoices_direction') THEN
|
||||
ALTER TABLE club_invoices
|
||||
ADD CONSTRAINT chk_club_invoices_direction
|
||||
CHECK (invoice_direction IN ('incoming', 'outgoing'));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
ALTER TABLE IF EXISTS club_communication_recipients
|
||||
ADD COLUMN IF NOT EXISTS last_attempt_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS retryable boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS error_code varchar(64),
|
||||
ADD COLUMN IF NOT EXISTS transport_message_id varchar(255);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_communication_delivery_logs (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
thread_id bigint NOT NULL,
|
||||
recipient_id bigint,
|
||||
created_by_user_id bigint,
|
||||
status varchar(32) NOT NULL DEFAULT 'failed',
|
||||
attempt_no integer NOT NULL DEFAULT 1,
|
||||
retryable boolean NOT NULL DEFAULT false,
|
||||
error_code varchar(64),
|
||||
error_message text,
|
||||
transport_message_id varchar(255),
|
||||
transport_response text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_thread
|
||||
ON club_communication_delivery_logs (thread_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_communication_delivery_logs_recipient
|
||||
ON club_communication_delivery_logs (recipient_id);
|
||||
|
||||
ALTER TABLE IF EXISTS club_distribution_groups
|
||||
ADD COLUMN IF NOT EXISTS filter_definition jsonb;
|
||||
|
||||
ALTER TABLE IF EXISTS club_communication_threads
|
||||
ADD COLUMN IF NOT EXISTS recipient_filters jsonb;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_communication_templates (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
name varchar(160) NOT NULL,
|
||||
category varchar(64) NOT NULL DEFAULT 'general',
|
||||
subject_template varchar(255),
|
||||
body_template text,
|
||||
variables_hint text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_communication_templates_club
|
||||
ON club_communication_templates (club_id, category, name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_account_transactions (
|
||||
id bigserial PRIMARY KEY,
|
||||
club_id bigint NOT NULL,
|
||||
account_id bigint NOT NULL,
|
||||
invoice_id bigint,
|
||||
created_by_user_id bigint,
|
||||
direction varchar(16) NOT NULL DEFAULT 'credit',
|
||||
booking_type varchar(32) NOT NULL DEFAULT 'manual',
|
||||
status varchar(32) NOT NULL DEFAULT 'booked',
|
||||
booking_date date NOT NULL,
|
||||
value_date date,
|
||||
amount_cents bigint NOT NULL DEFAULT 0,
|
||||
currency_code varchar(3) NOT NULL DEFAULT 'EUR',
|
||||
reference varchar(255),
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_account_transactions_club_date
|
||||
ON club_account_transactions (club_id, booking_date DESC, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_account_transactions_account_date
|
||||
ON club_account_transactions (account_id, booking_date DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_account_transactions_invoice
|
||||
ON club_account_transactions (invoice_id);
|
||||
|
||||
COMMIT;
|
||||
@@ -4,7 +4,7 @@
|
||||
<h1>
|
||||
<router-link to="/" class="home-link">
|
||||
<img :src="logoUrl" alt="Logo" class="home-logo" width="24" height="24" loading="lazy" />
|
||||
<span>{{ $t('app.name') }}</span>
|
||||
<span>{{ appBrand }}</span>
|
||||
</router-link>
|
||||
</h1>
|
||||
<div v-if="isAuthenticated" class="user-menu">
|
||||
@@ -26,19 +26,19 @@
|
||||
<span class="dropdown-icon">📦</span>
|
||||
{{ $t('navigation.orders') }}
|
||||
</router-link>
|
||||
<button v-if="canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('PermissionsView', $t('navigation.permissions'))">
|
||||
<button v-if="isFullAppProduct && canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('PermissionsView', $t('navigation.permissions'))">
|
||||
<span class="dropdown-icon">🔐</span>
|
||||
{{ $t('navigation.permissions') }}
|
||||
</button>
|
||||
<button v-if="hasPermission('members', 'write')" type="button" class="dropdown-item" @click="openUserMenuDialog('MemberTransferSettingsView', $t('navigation.memberTransfer'))">
|
||||
<button v-if="isFullAppProduct && hasPermission('members', 'write')" type="button" class="dropdown-item" @click="openUserMenuDialog('MemberTransferSettingsView', $t('navigation.memberTransfer'))">
|
||||
<span class="dropdown-icon">📤</span>
|
||||
{{ $t('navigation.memberTransfer') }}
|
||||
</button>
|
||||
<button v-if="isAdmin" type="button" class="dropdown-item" @click="openUserMenuDialog('LogsView', $t('navigation.logs'))">
|
||||
<button v-if="isFullAppProduct && isAdmin" type="button" class="dropdown-item" @click="openUserMenuDialog('LogsView', $t('navigation.logs'))">
|
||||
<span class="dropdown-icon">📋</span>
|
||||
{{ $t('navigation.logs') }}
|
||||
</button>
|
||||
<button v-if="canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('ClickTtView', $t('navigation.clickTtBrowser'))">
|
||||
<button v-if="isFullAppProduct && canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('ClickTtView', $t('navigation.clickTtBrowser'))">
|
||||
<span class="dropdown-icon">🌐</span>
|
||||
{{ $t('navigation.clickTtBrowser') }}
|
||||
</button>
|
||||
@@ -57,13 +57,13 @@
|
||||
</header>
|
||||
|
||||
<div class="app-container">
|
||||
<aside v-if="isAuthenticated" class="sidebar" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||
<aside v-if="shouldRenderSidebar" class="sidebar" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||
<button class="sidebar-toggle" @click="toggleSidebar">
|
||||
<span v-if="sidebarCollapsed">→</span>
|
||||
<span v-else>←</span>
|
||||
</button>
|
||||
<div class="sidebar-content">
|
||||
<div class="club-selector card">
|
||||
<div v-if="shouldShowClubSelector" class="club-selector card">
|
||||
<h3 class="card-title">{{ $t('club.select') }}</h3>
|
||||
<div class="select-group">
|
||||
<select v-model="selectedClub" class="club-select" @change="handleClubSelectionChange">
|
||||
@@ -74,68 +74,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav v-if="selectedClub" class="nav-menu">
|
||||
<div class="nav-section">
|
||||
<h4 class="nav-title">{{ $t('navigation.dailyBusiness') }}</h4>
|
||||
<router-link v-if="hasPermission('members', 'read')" to="/members" class="nav-link" title="Mitglieder">
|
||||
<span class="nav-icon">👥</span>
|
||||
{{ $t('navigation.members') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('diary', 'read')" to="/diary" class="nav-link" title="Tagebuch">
|
||||
<span class="nav-icon">📝</span>
|
||||
{{ $t('navigation.diary') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('diary', 'read') || hasPermission('schedule', 'read') || hasPermission('tournaments', 'read')" to="/calendar" class="nav-link" title="Kalender">
|
||||
<span class="nav-icon">📆</span>
|
||||
Kalender
|
||||
</router-link>
|
||||
<router-link v-if="canManageApprovals" to="/pending-approvals" class="nav-link" title="Freigaben">
|
||||
<span class="nav-icon">⏳</span>
|
||||
{{ $t('navigation.approvals') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('statistics', 'read')" to="/training-stats" class="nav-link" title="Trainings-Statistik">
|
||||
<span class="nav-icon">📊</span>
|
||||
{{ $t('navigation.statistics') }}
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4 class="nav-title">{{ $t('navigation.competitions') }}</h4>
|
||||
<router-link v-if="hasPermission('tournaments', 'read')" to="/tournaments" class="nav-link" :title="$t('navigation.clubTournaments')">
|
||||
<span class="nav-icon">🏆</span>
|
||||
{{ $t('navigation.clubTournaments') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('tournaments', 'read')" to="/tournament-participations" class="nav-link" :title="$t('navigation.tournamentParticipations')">
|
||||
<span class="nav-icon">📋</span>
|
||||
{{ $t('navigation.tournamentParticipations') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('schedule', 'read')" to="/schedule" class="nav-link" title="Spielpläne">
|
||||
<span class="nav-icon">📅</span>
|
||||
{{ $t('navigation.schedule') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('schedule', 'read')" to="/friendly-matches" class="nav-link" title="Freundschaftsspiele">
|
||||
<span class="nav-icon">🤝</span>
|
||||
Freundschaftsspiele
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<h4 class="nav-title">{{ $t('navigation.settings') }}</h4>
|
||||
<router-link v-if="isAdmin" to="/club-settings" class="nav-link" title="Vereinseinstellungen">
|
||||
<span class="nav-icon">🏛️</span>
|
||||
{{ $t('navigation.clubSettings') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('predefined_activities', 'read')" to="/predefined-activities" class="nav-link" title="Vordefinierte Aktivitäten">
|
||||
<span class="nav-icon">🎯</span>
|
||||
{{ $t('navigation.predefinedActivities') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('teams', 'read')" to="/team-management" class="nav-link" title="Team-Verwaltung">
|
||||
<span class="nav-icon">🧩</span>
|
||||
{{ $t('navigation.teamManagement') }}
|
||||
</router-link>
|
||||
<router-link v-if="hasPermission('members', 'read')" to="/billing" class="nav-link" :title="$t('navigation.billing')">
|
||||
<span class="nav-icon">🧾</span>
|
||||
{{ $t('navigation.billing') }}
|
||||
<nav v-if="sidebarSections.length" class="nav-menu">
|
||||
<div v-for="section in sidebarSections" :key="section.id" class="nav-section">
|
||||
<h4 class="nav-title">{{ resolveSectionTitle(section) }}</h4>
|
||||
<router-link
|
||||
v-for="item in section.items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="nav-link"
|
||||
:title="resolveNavItemLabel(item)"
|
||||
>
|
||||
<span class="nav-icon">{{ item.icon }}</span>
|
||||
{{ resolveNavItemLabel(item) }}
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -160,6 +110,7 @@
|
||||
</div>
|
||||
|
||||
<BaseDialog
|
||||
v-if="isFullAppProduct"
|
||||
v-model="showMobileClubPicker"
|
||||
:title="$t('club.select')"
|
||||
:max-width="420"
|
||||
@@ -237,6 +188,7 @@ import InfoDialog from './components/InfoDialog.vue';
|
||||
import ConfirmDialog from './components/ConfirmDialog.vue';
|
||||
import BaseDialog from './components/BaseDialog.vue';
|
||||
import { buildInfoConfig, buildConfirmConfig } from './utils/dialogUtils.js';
|
||||
import { FULL_APP_PRODUCTS, SIDEBAR_NAVIGATION } from './config/products.js';
|
||||
|
||||
const DialogManager = defineAsyncComponent(() => import('./components/DialogManager.vue'));
|
||||
export default {
|
||||
@@ -274,7 +226,28 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters(['isAuthenticated', 'currentClub', 'clubs', 'sidebarCollapsed', 'username', 'hasPermission', 'isClubOwner', 'userRole', 'language']),
|
||||
...mapGetters([
|
||||
'isAuthenticated',
|
||||
'currentClub',
|
||||
'clubs',
|
||||
'sidebarCollapsed',
|
||||
'username',
|
||||
'hasPermission',
|
||||
'isClubOwner',
|
||||
'userRole',
|
||||
'language',
|
||||
'appProduct',
|
||||
'appBrand',
|
||||
]),
|
||||
isClubProduct() {
|
||||
return this.appProduct === 'club';
|
||||
},
|
||||
isFullAppProduct() {
|
||||
return FULL_APP_PRODUCTS.includes(this.appProduct);
|
||||
},
|
||||
isPlayerProduct() {
|
||||
return this.appProduct === 'player';
|
||||
},
|
||||
isMobileViewport() {
|
||||
return this.viewportWidth <= 768;
|
||||
},
|
||||
@@ -303,10 +276,36 @@ export default {
|
||||
viewReloadKey() {
|
||||
return `${this.$route.fullPath}|${this.currentClub || 'no-club'}`;
|
||||
},
|
||||
shouldShowClubSelector() {
|
||||
return this.isFullAppProduct;
|
||||
},
|
||||
shouldRenderSidebar() {
|
||||
return this.isAuthenticated;
|
||||
},
|
||||
sidebarSections() {
|
||||
const baseSections = SIDEBAR_NAVIGATION[this.appProduct] || [];
|
||||
const currentSections = [];
|
||||
|
||||
for (const section of baseSections) {
|
||||
const items = section.items.filter((item) => this.isNavItemVisible(item));
|
||||
if (items.length > 0) {
|
||||
currentSections.push({
|
||||
...section,
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isFullAppProduct && !this.selectedClub) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return currentSections;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentClub(newVal) {
|
||||
if (newVal === 'new') {
|
||||
if (this.isFullAppProduct && newVal === 'new') {
|
||||
this.$router.push('/createclub');
|
||||
}
|
||||
if (newVal) {
|
||||
@@ -337,6 +336,28 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resolveSectionTitle(section) {
|
||||
return section.titleKey ? this.$t(section.titleKey) : section.title;
|
||||
},
|
||||
resolveNavItemLabel(item) {
|
||||
return item.labelKey ? this.$t(item.labelKey) : item.label;
|
||||
},
|
||||
isNavItemVisible(item) {
|
||||
if (item.capability === 'approvals') {
|
||||
return this.canManageApprovals;
|
||||
}
|
||||
if (item.capability === 'admin') {
|
||||
return this.isAdmin;
|
||||
}
|
||||
if (item.permission) {
|
||||
const [resource, action] = item.permission;
|
||||
return this.hasPermission(resource, action);
|
||||
}
|
||||
if (Array.isArray(item.anyPermission)) {
|
||||
return item.anyPermission.some(([resource, action]) => this.hasPermission(resource, action));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
handleViewportResize() {
|
||||
this.viewportWidth = window.innerWidth;
|
||||
this.updateMobileClubPickerState();
|
||||
@@ -399,6 +420,10 @@ export default {
|
||||
},
|
||||
|
||||
async handleClubSelectionChange() {
|
||||
if (!this.isFullAppProduct) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selectedClub) {
|
||||
await this.setCurrentClub(null);
|
||||
this.updateMobileClubPickerState();
|
||||
@@ -413,6 +438,10 @@ export default {
|
||||
},
|
||||
|
||||
async selectClubFromMobilePicker(clubId) {
|
||||
if (!this.isFullAppProduct) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedClub = clubId;
|
||||
await this.handleClubSelectionChange();
|
||||
},
|
||||
@@ -423,6 +452,12 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isFullAppProduct) {
|
||||
this.selectedClub = this.currentClub;
|
||||
this.showMobileClubPicker = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.currentClub) {
|
||||
this.selectedClub = this.currentClub;
|
||||
this.showMobileClubPicker = false;
|
||||
@@ -444,7 +479,7 @@ export default {
|
||||
},
|
||||
|
||||
updateMobileClubPickerState() {
|
||||
if (!this.isAuthenticated || !this.isMobileViewport || this.currentClub || this.$route.path === '/createclub') {
|
||||
if (!this.isFullAppProduct || !this.isAuthenticated || !this.isMobileViewport || this.currentClub || this.$route.path === '/createclub') {
|
||||
this.showMobileClubPicker = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<template>
|
||||
<section class="diary-sidebar-section">
|
||||
<h3>{{ $t('diary.participants') }} ({{ participants.length }})</h3>
|
||||
<p class="participant-summary">
|
||||
{{ $t('diary.excusedParticipants') }}: {{ excusedCount }} | {{ $t('diary.availableParticipants') }}: {{ availableParticipantCount }} | {{ $t('diary.activeMembers') }}: {{ activeMemberCount }}
|
||||
</p>
|
||||
<div class="participant-toolbar">
|
||||
<div class="participant-toolbar-actions">
|
||||
<button
|
||||
@@ -112,6 +115,18 @@ export default {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
excusedCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
activeMemberCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
availableParticipantCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
galleryLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -162,6 +177,12 @@ export default {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.participant-summary {
|
||||
margin: -0.35rem 0 0.85rem;
|
||||
color: var(--text-muted, #5f7b8b);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.participant-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
163
frontend/src/components/RichTextEditor.vue
Normal file
163
frontend/src/components/RichTextEditor.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="rich-text-editor" :class="{ disabled }">
|
||||
<div class="editor-toolbar" v-if="!disabled">
|
||||
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('bold')"><strong>B</strong></button>
|
||||
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('italic')"><em>I</em></button>
|
||||
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('underline')"><u>U</u></button>
|
||||
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('insertUnorderedList')">• Liste</button>
|
||||
<button type="button" class="toolbar-button" @mousedown.prevent @click="format('insertOrderedList')">1. Liste</button>
|
||||
<button type="button" class="toolbar-button" @mousedown.prevent="insertLink">Link</button>
|
||||
<button type="button" class="toolbar-button" @mousedown.prevent @click="clearFormatting">Format löschen</button>
|
||||
</div>
|
||||
<div
|
||||
ref="editor"
|
||||
class="editor-surface"
|
||||
:class="{ placeholder: !currentHtml && placeholder }"
|
||||
:contenteditable="!disabled"
|
||||
:data-placeholder="placeholder"
|
||||
:style="{ minHeight }"
|
||||
@input="handleInput"
|
||||
@blur="handleBlur"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { sanitizeRichTextHtml } from '../utils/richTextDocumentExport.js';
|
||||
|
||||
export default {
|
||||
name: 'RichTextEditor',
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
minHeight: {
|
||||
type: String,
|
||||
default: '180px',
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return {
|
||||
currentHtml: '',
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
immediate: true,
|
||||
handler(value) {
|
||||
const sanitized = sanitizeRichTextHtml(value || '');
|
||||
this.currentHtml = sanitized;
|
||||
if (this.$refs.editor && this.$refs.editor.innerHTML !== sanitized) {
|
||||
this.$refs.editor.innerHTML = sanitized;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.syncEditor();
|
||||
},
|
||||
methods: {
|
||||
syncEditor() {
|
||||
if (!this.$refs.editor) return;
|
||||
const sanitized = sanitizeRichTextHtml(this.modelValue || '');
|
||||
this.$refs.editor.innerHTML = sanitized;
|
||||
this.currentHtml = sanitized;
|
||||
},
|
||||
emitCurrentValue() {
|
||||
const nextHtml = sanitizeRichTextHtml(this.$refs.editor?.innerHTML || '');
|
||||
this.currentHtml = nextHtml;
|
||||
this.$emit('update:modelValue', nextHtml);
|
||||
},
|
||||
handleInput() {
|
||||
this.emitCurrentValue();
|
||||
},
|
||||
handleBlur() {
|
||||
this.emitCurrentValue();
|
||||
},
|
||||
handlePaste(event) {
|
||||
if (this.disabled) return;
|
||||
event.preventDefault();
|
||||
const html = event.clipboardData?.getData('text/html');
|
||||
const text = event.clipboardData?.getData('text/plain') || '';
|
||||
const value = sanitizeRichTextHtml(html || text.replace(/\n/g, '<br>'));
|
||||
document.execCommand('insertHTML', false, value || text.replace(/\n/g, '<br>'));
|
||||
this.emitCurrentValue();
|
||||
},
|
||||
format(command) {
|
||||
if (this.disabled) return;
|
||||
this.$refs.editor?.focus();
|
||||
document.execCommand(command, false, null);
|
||||
this.emitCurrentValue();
|
||||
},
|
||||
insertLink() {
|
||||
if (this.disabled) return;
|
||||
const url = window.prompt('Link einfügen', 'https://');
|
||||
if (!url) return;
|
||||
this.$refs.editor?.focus();
|
||||
document.execCommand('createLink', false, url);
|
||||
this.emitCurrentValue();
|
||||
},
|
||||
clearFormatting() {
|
||||
if (this.disabled) return;
|
||||
this.$refs.editor?.focus();
|
||||
document.execCommand('removeFormat', false, null);
|
||||
this.emitCurrentValue();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rich-text-editor {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
border: 1px solid rgba(24, 70, 54, 0.12);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 10px;
|
||||
padding: 0.35rem 0.6rem;
|
||||
}
|
||||
|
||||
.editor-surface {
|
||||
border: 1px solid rgba(24, 70, 54, 0.16);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
padding: 0.9rem;
|
||||
line-height: 1.55;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.editor-surface:focus {
|
||||
outline: 2px solid rgba(47, 122, 95, 0.25);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.editor-surface.placeholder:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rich-text-editor.disabled .editor-surface {
|
||||
background: rgba(245, 247, 250, 0.95);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -28,10 +28,22 @@
|
||||
<span class="diary-stat-label">{{ $t('diary.participants') }}</span>
|
||||
<strong class="diary-stat-value">{{ participantCount }}</strong>
|
||||
</div>
|
||||
<div class="diary-stat-card">
|
||||
<span class="diary-stat-label">{{ $t('diary.excusedParticipants') }}</span>
|
||||
<strong class="diary-stat-value">{{ excusedCount }}</strong>
|
||||
</div>
|
||||
<div class="diary-stat-card">
|
||||
<span class="diary-stat-label">{{ $t('diary.availableParticipants') }}</span>
|
||||
<strong class="diary-stat-value">{{ availableParticipantCount }}</strong>
|
||||
</div>
|
||||
<div class="diary-stat-card">
|
||||
<span class="diary-stat-label">{{ $t('diary.trainingPlan') }}</span>
|
||||
<strong class="diary-stat-value">{{ trainingPlanCount }}</strong>
|
||||
</div>
|
||||
<div class="diary-stat-card">
|
||||
<span class="diary-stat-label">{{ $t('diary.activeMembers') }}</span>
|
||||
<strong class="diary-stat-value">{{ activeMemberCount }}</strong>
|
||||
</div>
|
||||
<div class="diary-stat-card">
|
||||
<span class="diary-stat-label">{{ $t('diary.freeActivities') }}</span>
|
||||
<strong class="diary-stat-value">{{ activitiesCount }}</strong>
|
||||
@@ -127,6 +139,9 @@ export default {
|
||||
diaryStatusText: { type: String, default: '' },
|
||||
diaryTimeRangeLabel: { type: String, default: '' },
|
||||
participantCount: { type: Number, default: 0 },
|
||||
excusedCount: { type: Number, default: 0 },
|
||||
activeMemberCount: { type: Number, default: 0 },
|
||||
availableParticipantCount: { type: Number, default: 0 },
|
||||
trainingPlanCount: { type: Number, default: 0 },
|
||||
activitiesCount: { type: Number, default: 0 },
|
||||
trainingStart: { type: String, default: '' },
|
||||
@@ -214,9 +229,9 @@ export default {
|
||||
|
||||
.diary-workspace-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(110px, 1fr));
|
||||
gap: 0.75rem;
|
||||
width: min(560px, 100%);
|
||||
width: min(840px, 100%);
|
||||
}
|
||||
|
||||
.diary-stat-card {
|
||||
@@ -308,7 +323,7 @@ export default {
|
||||
}
|
||||
|
||||
.diary-workspace-stats {
|
||||
grid-template-columns: repeat(2, minmax(92px, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(92px, 1fr));
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
360
frontend/src/config/clubDataModels.js
Normal file
360
frontend/src/config/clubDataModels.js
Normal file
@@ -0,0 +1,360 @@
|
||||
export const CLUB_DATA_MODELS = {
|
||||
club: {
|
||||
table: 'clubs',
|
||||
purpose: 'Vereinsstammdaten und organisatorische Grundeinstellungen für TT-Verein.',
|
||||
fields: [
|
||||
'id',
|
||||
'name',
|
||||
'short_name',
|
||||
'legal_name',
|
||||
'club_number',
|
||||
'email',
|
||||
'phone',
|
||||
'website',
|
||||
'street',
|
||||
'postal_code',
|
||||
'city',
|
||||
'country_code',
|
||||
'chairperson_name',
|
||||
'treasurer_name',
|
||||
'youth_manager_name',
|
||||
'creditor_identifier',
|
||||
'billing_email',
|
||||
'iban',
|
||||
'bic',
|
||||
'fee_rules',
|
||||
'is_archived',
|
||||
'archived_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
},
|
||||
member: {
|
||||
table: 'clubmembers',
|
||||
purpose: 'Mitglied als zentrale Person im Verein mit Stammdaten, Status und Zahlungsbezug.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'user_id',
|
||||
'member_number',
|
||||
'membership_status',
|
||||
'membership_type',
|
||||
'joined_on',
|
||||
'left_on',
|
||||
'birthdate',
|
||||
'email',
|
||||
'phone',
|
||||
'street',
|
||||
'postal_code',
|
||||
'city',
|
||||
'country_code',
|
||||
'contribution_group_code',
|
||||
'needs_sepa_mandate',
|
||||
'sepa_mandate_reference',
|
||||
'is_archived',
|
||||
'archived_at',
|
||||
'archived_reason',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
},
|
||||
request: {
|
||||
table: 'club_requests',
|
||||
purpose: 'Zentraler Eingang für Kontakt-, Probetraining-, Mitgliedschafts- und Sponsoringanfragen.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'request_type',
|
||||
'status',
|
||||
'workflow_stage',
|
||||
'priority',
|
||||
'source_system',
|
||||
'source_reference',
|
||||
'subject',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'email',
|
||||
'phone',
|
||||
'birthdate',
|
||||
'message',
|
||||
'assigned_user_id',
|
||||
'assigned_member_id',
|
||||
'converted_member_id',
|
||||
'received_at',
|
||||
'closed_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
},
|
||||
communicationThread: {
|
||||
table: 'club_communication_threads',
|
||||
purpose: 'Gesprächsstrang für Einzelnachrichten, Rundschreiben und spätere interne Kommunikation.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'thread_type',
|
||||
'subject',
|
||||
'status',
|
||||
'created_by_user_id',
|
||||
'scheduled_at',
|
||||
'sent_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
},
|
||||
distributionGroup: {
|
||||
table: 'club_distribution_groups',
|
||||
purpose: 'Wiederverwendbare Verteilergruppen für Kommunikation.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'name',
|
||||
'description',
|
||||
'group_type',
|
||||
'is_system_group',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
},
|
||||
event: {
|
||||
table: 'calendar_events',
|
||||
purpose: 'Vereinstermine für Training, Spiele und Vereinsveranstaltungen mit Fristen und Zuständigkeiten.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'event_type',
|
||||
'status',
|
||||
'title',
|
||||
'description',
|
||||
'location',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'registration_deadline',
|
||||
'organizer_user_id',
|
||||
'notes',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'archived_at',
|
||||
],
|
||||
},
|
||||
document: {
|
||||
table: 'club_documents',
|
||||
purpose: 'Dokumentenstamm für Satzung, Protokolle, Formulare und Vereinsdokumente mit Versionen, Sichtbarkeit und Belegbezug.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'document_type',
|
||||
'title',
|
||||
'description',
|
||||
'status',
|
||||
'current_version_no',
|
||||
'owner_user_id',
|
||||
'visibility_scope',
|
||||
'archived_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
},
|
||||
task: {
|
||||
table: 'club_tasks',
|
||||
purpose: 'Aufgaben, Wiedervorlagen und Fristen für den Vereinsbetrieb.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'title',
|
||||
'task_type',
|
||||
'description',
|
||||
'status',
|
||||
'priority',
|
||||
'due_at',
|
||||
'remind_at',
|
||||
'created_by_user_id',
|
||||
'assigned_user_id',
|
||||
'automation_source',
|
||||
'automation_key',
|
||||
'related_entity_type',
|
||||
'related_entity_id',
|
||||
'source_snapshot',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'completed_at',
|
||||
'archived_at',
|
||||
],
|
||||
},
|
||||
sponsor: {
|
||||
table: 'club_invoice_parties',
|
||||
purpose: 'Sponsorenbeziehung innerhalb der Rechnungsparteien mit Ansprechpartnern, Verträgen und Laufzeiten.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'party_type',
|
||||
'status',
|
||||
'name',
|
||||
'contract_reference',
|
||||
'valid_from',
|
||||
'valid_to',
|
||||
'contact_name',
|
||||
'email',
|
||||
'phone',
|
||||
'street',
|
||||
'postal_code',
|
||||
'city',
|
||||
'country_code',
|
||||
'iban',
|
||||
'bic',
|
||||
'tax_identifier',
|
||||
'notes',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
},
|
||||
feeRule: {
|
||||
table: 'club_fee_rules',
|
||||
purpose: 'Beitragssätze inklusive Familienbeiträgen und Ermäßigungen.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'code',
|
||||
'name',
|
||||
'category',
|
||||
'billing_cycle',
|
||||
'base_amount_cents',
|
||||
'currency_code',
|
||||
'age_from',
|
||||
'age_to',
|
||||
'is_family_rule',
|
||||
'is_reduction_rule',
|
||||
'valid_from',
|
||||
'valid_to',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
},
|
||||
sepaMandate: {
|
||||
table: 'club_sepa_mandates',
|
||||
purpose: 'SEPA-Mandate für Mitglieder oder Beitragszahler.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'member_id',
|
||||
'debtor_name',
|
||||
'iban',
|
||||
'bic',
|
||||
'mandate_reference',
|
||||
'signed_on',
|
||||
'valid_from',
|
||||
'revoked_at',
|
||||
'status',
|
||||
'history_note',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
},
|
||||
account: {
|
||||
table: 'club_accounts',
|
||||
purpose: 'Vereinskonten als Grundlage für Zahlungswege, SEPA-Einzüge und spätere Finanzprozesse.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'name',
|
||||
'account_holder',
|
||||
'bank_name',
|
||||
'iban',
|
||||
'bic',
|
||||
'account_type',
|
||||
'usage_type',
|
||||
'currency_code',
|
||||
'allow_sepa_collections',
|
||||
'allow_outgoing_payments',
|
||||
'is_default',
|
||||
'status',
|
||||
'notes',
|
||||
'sort_order',
|
||||
'archived_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
},
|
||||
paymentClaim: {
|
||||
table: 'club_payment_claims',
|
||||
purpose: 'Offene Beitragsforderungen und sonstige Zahlungsansprüche.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'member_id',
|
||||
'fee_rule_id',
|
||||
'claim_type',
|
||||
'status',
|
||||
'due_on',
|
||||
'amount_cents',
|
||||
'paid_amount_cents',
|
||||
'currency_code',
|
||||
'reminder_level',
|
||||
'last_reminder_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'settled_at',
|
||||
'last_paid_at',
|
||||
'archived_at',
|
||||
],
|
||||
},
|
||||
invoice: {
|
||||
table: 'club_invoices',
|
||||
purpose: 'Ein- und Ausgangsrechnungen mit Parteien- und Dokumentenbezug.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'invoice_direction',
|
||||
'invoice_type',
|
||||
'status',
|
||||
'invoice_number',
|
||||
'external_reference',
|
||||
'party_id',
|
||||
'account_id',
|
||||
'issued_on',
|
||||
'due_on',
|
||||
'paid_on',
|
||||
'net_amount_cents',
|
||||
'tax_amount_cents',
|
||||
'gross_amount_cents',
|
||||
'currency_code',
|
||||
'description',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'archived_at',
|
||||
],
|
||||
},
|
||||
historyEntry: {
|
||||
table: 'club_history_entries',
|
||||
purpose: 'Änderungs- und Aktivitätsprotokoll für jedes Vereinsmodul.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'action_type',
|
||||
'actor_user_id',
|
||||
'actor_member_id',
|
||||
'old_value_json',
|
||||
'new_value_json',
|
||||
'summary',
|
||||
'created_at',
|
||||
],
|
||||
},
|
||||
roleAssignment: {
|
||||
table: 'club_user_roles',
|
||||
purpose: 'Rollenbasierte Berechtigungszuordnung für Verwaltungsnutzer.',
|
||||
fields: [
|
||||
'id',
|
||||
'club_id',
|
||||
'user_id',
|
||||
'member_id',
|
||||
'role_code',
|
||||
'valid_from',
|
||||
'valid_to',
|
||||
'is_primary',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
],
|
||||
},
|
||||
};
|
||||
307
frontend/src/config/clubWorkspace.js
Normal file
307
frontend/src/config/clubWorkspace.js
Normal file
@@ -0,0 +1,307 @@
|
||||
export const CLUB_DASHBOARD_SECTIONS = [
|
||||
{
|
||||
id: 'action-needed',
|
||||
title: 'Handlungsbedarf',
|
||||
cards: [
|
||||
{
|
||||
title: 'Neue Anfragen',
|
||||
accent: 'amber',
|
||||
items: ['3 neue Probetrainings', '1 Sponsoringanfrage'],
|
||||
},
|
||||
{
|
||||
title: 'Offene Zahlungen',
|
||||
accent: 'red',
|
||||
items: ['7 Mitgliedsbeiträge offen', '2 Mahnungen fällig'],
|
||||
},
|
||||
{
|
||||
title: 'Fehlende Daten',
|
||||
accent: 'blue',
|
||||
items: ['5 Mitglieder ohne E-Mail', '2 Mitglieder ohne Geburtsdatum', '3 Mitglieder ohne SEPA-Mandat'],
|
||||
},
|
||||
{
|
||||
title: 'Offene Aufgaben',
|
||||
accent: 'green',
|
||||
items: ['Vereinsmeisterschaft planen', 'Hallendienst besetzen'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'today-and-week',
|
||||
title: 'Aktuelle Termine',
|
||||
cards: [
|
||||
{
|
||||
title: 'Heute',
|
||||
accent: 'green',
|
||||
items: ['Jugendtraining 17:00 Uhr', 'Vorstandssitzung 19:30 Uhr'],
|
||||
},
|
||||
{
|
||||
title: 'Diese Woche',
|
||||
accent: 'blue',
|
||||
items: ['Heimspiel Herren 1', 'Vereinsmeisterschaft Meldeschluss'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'club-status',
|
||||
title: 'Vereinsstatus',
|
||||
cards: [
|
||||
{
|
||||
title: 'Mitglieder',
|
||||
value: '87 aktiv',
|
||||
meta: '+4 dieses Jahr',
|
||||
accent: 'green',
|
||||
},
|
||||
{
|
||||
title: 'Anfragen',
|
||||
value: '12 offen',
|
||||
meta: '4 in Bearbeitung',
|
||||
accent: 'amber',
|
||||
},
|
||||
{
|
||||
title: 'Finanzen',
|
||||
value: '93 % bezahlt',
|
||||
meta: 'Mitgliedsbeiträge',
|
||||
accent: 'blue',
|
||||
},
|
||||
{
|
||||
title: 'Dokumente',
|
||||
value: '8 unbearbeitet',
|
||||
meta: 'Neue oder offene Unterlagen',
|
||||
accent: 'red',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'recent-activity',
|
||||
title: 'Letzte Aktivitäten',
|
||||
cards: [
|
||||
{
|
||||
title: 'Zuletzt passiert',
|
||||
accent: 'neutral',
|
||||
items: ['Mitglied angelegt', 'Rechnung bezahlt', 'Dokument hochgeladen', 'Anfrage beantwortet'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const CLUB_DASHBOARD_QUICK_LINKS = [
|
||||
{ to: '/club-tasks', label: 'Aufgaben steuern', icon: '✅', permission: ['tasks', 'read'] },
|
||||
{ to: '/club-requests', label: 'Anfragen bearbeiten', icon: '📥', permission: ['requests', 'read'] },
|
||||
{ to: '/members', label: 'Mitglieder öffnen', icon: '👥', permission: ['members', 'read'] },
|
||||
{ to: '/club-payments', label: 'Zahlungen prüfen', icon: '💶', permission: ['finance_accounts', 'read'] },
|
||||
{ to: '/club-documents', label: 'Dokumente verwalten', icon: '🗂️', permission: ['settings', 'read'] },
|
||||
];
|
||||
|
||||
export const CLUB_MENU_SECTIONS = [
|
||||
{
|
||||
id: 'main',
|
||||
title: 'Hauptmenü',
|
||||
items: [
|
||||
{ to: '/', icon: '🏠', label: 'Dashboard' },
|
||||
{ to: '/club-requests', icon: '📥', label: 'Anfragen', permission: ['requests', 'read'] },
|
||||
{ to: '/members', icon: '👥', label: 'Mitglieder', permission: ['members', 'read'] },
|
||||
{ to: '/club-communication', icon: '💬', label: 'Kommunikation', permission: ['communication', 'read'] },
|
||||
{ to: '/calendar', icon: '📆', label: 'Termine', permission: ['schedule', 'read'] },
|
||||
{ to: '/club-documents', icon: '🗂️', label: 'Dokumente', permission: ['settings', 'read'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'organisation',
|
||||
title: 'Organisation',
|
||||
items: [
|
||||
{ to: '/club-tasks', icon: '✅', label: 'Aufgaben', permission: ['tasks', 'read'] },
|
||||
{ to: '/team-management', icon: '🧩', label: 'Mannschaften', permission: ['teams', 'read'] },
|
||||
{ to: '/club-events', icon: '🎪', label: 'Veranstaltungen', permission: ['schedule', 'read'] },
|
||||
{ to: '/club-sponsors', icon: '🤝', label: 'Sponsoren', permission: ['settings', 'read'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
title: 'Finanzen',
|
||||
items: [
|
||||
{ to: '/club-fees', icon: '💳', label: 'Beiträge', permission: ['finance_invoices', 'write'] },
|
||||
{ to: '/club-payments', icon: '💶', label: 'Zahlungen', permission: ['finance_accounts', 'write'] },
|
||||
{ to: '/club-invoices', icon: '🧾', label: 'Rechnungen', permission: ['finance_invoices', 'write'] },
|
||||
{ to: '/club-accounts', icon: '🏦', label: 'Konten', permission: ['finance_accounts', 'write'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'administration',
|
||||
title: 'Verwaltung',
|
||||
items: [
|
||||
{ to: '/club-users', icon: '👤', label: 'Benutzer', permission: ['permissions', 'read'] },
|
||||
{ to: '/club-roles', icon: '🛡️', label: 'Rollen', permission: ['permissions', 'read'] },
|
||||
{ to: '/club-history', icon: '🕘', label: 'Historie', permission: ['history', 'read'] },
|
||||
{ to: '/club-settings', icon: '⚙️', label: 'Einstellungen', capability: 'admin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'analysis',
|
||||
title: 'Auswertung',
|
||||
items: [
|
||||
{ to: '/club-statistics', icon: '📊', label: 'Statistiken', permission: ['statistics', 'read'] },
|
||||
{ to: '/club-reports', icon: '📑', label: 'Berichte', permission: ['statistics', 'read'] },
|
||||
{ to: '/club-archive', icon: '🗄️', label: 'Archiv', permission: ['archive', 'read'] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const CLUB_CONCEPT_ROUTES = [
|
||||
{
|
||||
path: '/club-requests',
|
||||
name: 'club-requests',
|
||||
title: 'Anfragen',
|
||||
phase: 'Phase 1',
|
||||
summary: 'Kontaktanfragen, Probetrainings, Mitgliedschaftsanfragen und Sponsoringanfragen in einem einheitlichen Eingang.',
|
||||
highlights: ['Kontaktanfragen', 'Probetraining', 'Mitgliedschaftsanfragen', 'Sponsoringanfragen'],
|
||||
principles: ['Zentrale Eingangsliste statt verteilter E-Mail-Postfächer', 'Bearbeitungsstatus für jeden Vorgang', 'Überführung in Mitglieder, Aufgaben oder Kommunikation'],
|
||||
},
|
||||
{
|
||||
path: '/club-communication',
|
||||
name: 'club-communication',
|
||||
title: 'Kommunikation',
|
||||
phase: 'Phase 1',
|
||||
summary: 'Kommunikation für Einzelpersonen, Gruppen und Rundschreiben mit klarem Vereinskontext.',
|
||||
highlights: ['Einzelnachrichten', 'Rundschreiben', 'Verteilergruppen'],
|
||||
principles: ['Kommunikation direkt aus dem Vereinskontext', 'Nutzbar für Vorstand, Trainer und Verwaltung', 'Später API-fähig für externe Eingaben'],
|
||||
},
|
||||
{
|
||||
path: '/club-documents',
|
||||
name: 'club-documents',
|
||||
title: 'Dokumente',
|
||||
phase: 'Phase 1',
|
||||
summary: 'Vereinsdokumente, Formulare und Protokolle an einem zentralen Ort statt in verstreuten Ordnern.',
|
||||
highlights: ['Satzung', 'Protokolle', 'Formulare', 'Vereinsdokumente'],
|
||||
principles: ['Archiv statt Löschen', 'Berechtigungen pro Dokumententyp', 'Später erweiterbar Richtung DMS'],
|
||||
},
|
||||
{
|
||||
path: '/club-tasks',
|
||||
name: 'club-tasks',
|
||||
title: 'Aufgaben',
|
||||
phase: 'Phase 2',
|
||||
summary: 'Aufgaben, Wiedervorlagen und Fristen für den Vereinsbetrieb in einem einfachen Arbeitsbereich.',
|
||||
highlights: ['Aufgabenverwaltung', 'Wiedervorlagen', 'Fristen'],
|
||||
principles: ['Dashboard-getrieben: Was muss heute erledigt werden?', 'Verknüpfbar mit Anfragen, Veranstaltungen und Finanzen', 'Geeignet für Vorstand und Orga-Teams'],
|
||||
},
|
||||
{
|
||||
path: '/club-training',
|
||||
name: 'club-training',
|
||||
title: 'Training',
|
||||
phase: 'Phase 1',
|
||||
summary: 'Trainingsbezogene Vereinsorganisation als eigener Bereich innerhalb von TT-Verein.',
|
||||
highlights: ['Trainingskoordination', 'Abstimmung mit Terminen', 'Verknüpfung zu Mannschaften und Mitgliedern'],
|
||||
principles: ['Nicht trainerzentriert, sondern vereinsorganisatorisch', 'Saubere Schnittstelle zum Trainings-Tagebuch', 'Fokus auf Vereinsbetrieb'],
|
||||
},
|
||||
{
|
||||
path: '/club-events',
|
||||
name: 'club-events',
|
||||
title: 'Veranstaltungen',
|
||||
phase: 'Phase 1',
|
||||
summary: 'Planung von Vereinsveranstaltungen neben Training und Spielbetrieb.',
|
||||
highlights: ['Vereinsveranstaltungen', 'Fristen', 'Verantwortlichkeiten'],
|
||||
principles: ['Veranstaltungen sind eigene Vereinsobjekte', 'Organisation mit Aufgaben und Dokumenten verbinden', 'Dashboard-relevante Fristen sichtbar machen'],
|
||||
},
|
||||
{
|
||||
path: '/club-sponsors',
|
||||
name: 'club-sponsors',
|
||||
title: 'Sponsoren',
|
||||
phase: 'Phase 2',
|
||||
summary: 'Sponsorenliste, Ansprechpartner und Vertragsbezug als eigener Organisationsbereich.',
|
||||
highlights: ['Sponsorenliste', 'Ansprechpartner', 'Verträge'],
|
||||
principles: ['Sponsoring nicht nur als Anfrage, sondern als laufende Beziehung', 'Verknüpfbar mit Rechnungen und Dokumenten', 'Spätere Entwicklungsauswertung möglich'],
|
||||
},
|
||||
{
|
||||
path: '/club-fees',
|
||||
name: 'club-fees',
|
||||
title: 'Beiträge',
|
||||
phase: 'Phase 2',
|
||||
summary: 'Beitragssätze, Familienbeiträge und Ermäßigungen für die Vereinsverwaltung.',
|
||||
highlights: ['Beitragssätze', 'Familienbeiträge', 'Ermäßigungen'],
|
||||
principles: ['Mitglieder- und Zahlungsbezug aus einer Quelle', 'Grundlage für offene Beiträge und Mahnstufen', 'Keine externe Beitragsliste mehr nötig'],
|
||||
},
|
||||
{
|
||||
path: '/club-payments',
|
||||
name: 'club-payments',
|
||||
title: 'Zahlungen',
|
||||
phase: 'Phase 2',
|
||||
summary: 'Offene Beiträge, Zahlungseingänge und Mahnstufen für Vorstand und Kassenrolle.',
|
||||
highlights: ['Offene Beiträge', 'Zahlungseingänge', 'Mahnstufen'],
|
||||
principles: ['Dashboard zeigt offenen Handlungsbedarf', 'Mitgliederdaten und Beiträge greifen zusammen', 'Basis für spätere SEPA-Workflows'],
|
||||
},
|
||||
{
|
||||
path: '/club-invoices',
|
||||
name: 'club-invoices',
|
||||
title: 'Rechnungen',
|
||||
phase: 'Phase 2',
|
||||
summary: 'Eingangs- und Ausgangsrechnungen in einem durchgängigen Vereinskontext.',
|
||||
highlights: ['Hallenmiete', 'Verbandsbeiträge', 'Material', 'Sponsoren und sonstige Forderungen'],
|
||||
principles: ['Trennung von Einnahmen und Ausgaben', 'Belegbezug zu Dokumenten und Sponsoren', 'Historie und Archiv standardmäßig vorgesehen'],
|
||||
},
|
||||
{
|
||||
path: '/club-accounts',
|
||||
name: 'club-accounts',
|
||||
title: 'Konten',
|
||||
phase: 'Phase 2',
|
||||
summary: 'Finanzkonten als organisatorische Grundlage für Zahlungen, Rechnungen und spätere SEPA-Prozesse.',
|
||||
highlights: ['Kontenübersicht', 'Kontobezug für Zahlungen', 'Vorbereitung für SEPA'],
|
||||
principles: ['Nicht Buchhaltung im Vollsinn, sondern Vereinsorganisation', 'Nachvollziehbare Zuordnung von Zahlungswegen', 'Grundlage für Kassenprozesse'],
|
||||
},
|
||||
{
|
||||
path: '/club-users',
|
||||
name: 'club-users',
|
||||
title: 'Benutzer',
|
||||
phase: 'Phase 1',
|
||||
summary: 'Benutzerverwaltung für die Personen, die im Verein mit der Plattform arbeiten.',
|
||||
highlights: ['Vorstand', 'Kassierer', 'Trainer', 'Jugendwart', 'Schriftführer', 'Mitglied'],
|
||||
principles: ['Nicht jedes Mitglied ist automatisch Verwaltungsnutzer', 'Rollenbasiert statt frei erfundener Einzelrechte', 'Sauber trennbar von Vereinsmitgliedern'],
|
||||
},
|
||||
{
|
||||
path: '/club-roles',
|
||||
name: 'club-roles',
|
||||
title: 'Rollen',
|
||||
phase: 'Phase 1',
|
||||
summary: 'Rollenbasierte Zugriffslogik für alle Vereinsmodule.',
|
||||
highlights: ['Vorstand', 'Kassierer', 'Trainer', 'Jugendwart', 'Schriftführer', 'Mitglied'],
|
||||
principles: ['Jedes Modul prüft Berechtigungen', 'Rollen sind produktzentral, nicht nachträglich angeheftet', 'Grundlage für Historie und API-Fähigkeit'],
|
||||
},
|
||||
{
|
||||
path: '/club-history',
|
||||
name: 'club-history',
|
||||
title: 'Historie',
|
||||
phase: 'Phase 1',
|
||||
summary: 'Änderungsprotokoll, Benutzerprotokoll und Aktivitäten als durchgängiges Grundprinzip.',
|
||||
highlights: ['Wer?', 'Wann?', 'Was?', 'Alter Wert', 'Neuer Wert'],
|
||||
principles: ['Historie überall', 'Archiv statt Löschen', 'Nachvollziehbarkeit für den Vereinsbetrieb'],
|
||||
},
|
||||
{
|
||||
path: '/club-statistics',
|
||||
name: 'club-statistics',
|
||||
title: 'Statistiken',
|
||||
phase: 'Phase 3',
|
||||
summary: 'Spätere Auswertung zu Mitgliederentwicklung, Altersstruktur, Beitragsentwicklung und Sponsorenentwicklung.',
|
||||
highlights: ['Mitgliederentwicklung', 'Altersstruktur', 'Beitragsentwicklung', 'Sponsorenentwicklung'],
|
||||
principles: ['Dashboard vor Statistik', 'Statistiken folgen erst auf belastbare Prozesse', 'Auswertungen bauen auf denselben Grunddaten auf'],
|
||||
},
|
||||
{
|
||||
path: '/club-reports',
|
||||
name: 'club-reports',
|
||||
title: 'Berichte',
|
||||
phase: 'Phase 3',
|
||||
summary: 'Berichte, Schriftverkehr und spätere PDF-Ausgabe für den Vereinsalltag.',
|
||||
highlights: ['Briefeditor', 'PDF-Erzeugung', 'Vorlagenverwaltung'],
|
||||
principles: ['Vorlagen für wiederkehrende Vereinsprozesse', 'Geeignet für Aufnahme, Mahnung und Einladungen', 'Sauber mit Dokumenten und Historie verzahnt'],
|
||||
},
|
||||
{
|
||||
path: '/club-archive',
|
||||
name: 'club-archive',
|
||||
title: 'Archiv',
|
||||
phase: 'Phase 3',
|
||||
summary: 'Archivierte Mitglieder, historische Dokumente und alte Rechnungen als eigene Auswertungsebene.',
|
||||
highlights: ['Ehemalige Mitglieder', 'Historische Dokumente', 'Alte Rechnungen'],
|
||||
principles: ['Archiv statt Löschen ist Standard', 'Ruhige Trennung von aktivem Bestand und Historie', 'Verknüpfbar mit Dokumenten und Historie'],
|
||||
},
|
||||
];
|
||||
|
||||
export function getClubConceptRouteByPath(path) {
|
||||
return CLUB_CONCEPT_ROUTES.find((route) => route.path === path) || null;
|
||||
}
|
||||
174
frontend/src/config/products.js
Normal file
174
frontend/src/config/products.js
Normal file
@@ -0,0 +1,174 @@
|
||||
import { CLUB_MENU_SECTIONS } from './clubWorkspace.js';
|
||||
|
||||
export const PRODUCT_TRAINER = 'trainer';
|
||||
export const PRODUCT_CLUB = 'club';
|
||||
export const PRODUCT_PLAYER = 'player';
|
||||
|
||||
export const FULL_APP_PRODUCTS = [PRODUCT_TRAINER, PRODUCT_CLUB];
|
||||
|
||||
const PRODUCT_HOSTS = {
|
||||
[PRODUCT_TRAINER]: ['tt-tagebuch.de', 'www.tt-tagebuch.de', 'trainer.localhost'],
|
||||
[PRODUCT_CLUB]: ['tt-verein.de', 'www.tt-verein.de', 'club.localhost'],
|
||||
[PRODUCT_PLAYER]: ['mein-tt.de', 'www.mein-tt.de', 'player.localhost'],
|
||||
};
|
||||
|
||||
const PRODUCT_CONFIGS = {
|
||||
[PRODUCT_TRAINER]: {
|
||||
id: PRODUCT_TRAINER,
|
||||
brandName: 'Trainings-Tagebuch',
|
||||
appName: 'tt-tagebuch.de',
|
||||
defaultHomeRoute: '/',
|
||||
canonicalUrl: import.meta.env.VITE_CANONICAL_TRAINER_URL || 'https://tt-tagebuch.de',
|
||||
seo: {
|
||||
siteName: 'Trainings-Tagebuch',
|
||||
defaultTitle: 'Trainings-Tagebuch – Tischtennis-Trainingsverwaltung',
|
||||
defaultDescription:
|
||||
'Trainer ist die bisherige vollständige Tischtennis-Anwendung für Training, Organisation, Turniere, Teams und Vereinsalltag.',
|
||||
imagePath: '/android-chrome-512x512.png',
|
||||
},
|
||||
},
|
||||
[PRODUCT_CLUB]: {
|
||||
id: PRODUCT_CLUB,
|
||||
brandName: 'TT Verein',
|
||||
appName: 'tt-verein.de',
|
||||
defaultHomeRoute: '/',
|
||||
canonicalUrl: import.meta.env.VITE_CANONICAL_CLUB_URL || 'https://tt-verein.de',
|
||||
seo: {
|
||||
siteName: 'TT Verein',
|
||||
defaultTitle: 'TT Verein – Vereinsverwaltung für Tischtennis',
|
||||
defaultDescription:
|
||||
'TT Verein ist die zentrale Arbeitsplattform für kleine und mittlere Tischtennisvereine mit Fokus auf Mitglieder, Kommunikation, Dokumente, Termine und Vereinsbetrieb.',
|
||||
imagePath: '/android-chrome-512x512.png',
|
||||
},
|
||||
},
|
||||
[PRODUCT_PLAYER]: {
|
||||
id: PRODUCT_PLAYER,
|
||||
brandName: 'Mein TT',
|
||||
appName: 'mein-tt.de',
|
||||
defaultHomeRoute: '/',
|
||||
canonicalUrl: import.meta.env.VITE_CANONICAL_PLAYER_URL || 'https://mein-tt.de',
|
||||
seo: {
|
||||
siteName: 'Mein TT',
|
||||
defaultTitle: 'Mein TT – Tischtennis für Spieler',
|
||||
defaultDescription:
|
||||
'Mein TT ist die persönliche Tischtennisoberfläche für Spieler mit Kalender, Kontoverknüpfungen, Bestellungen und persönlichen Einstellungen.',
|
||||
imagePath: '/android-chrome-512x512.png',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const SIDEBAR_NAVIGATION = {
|
||||
[PRODUCT_TRAINER]: [
|
||||
{
|
||||
id: 'daily-business',
|
||||
titleKey: 'navigation.dailyBusiness',
|
||||
items: [
|
||||
{ to: '/members', icon: '👥', labelKey: 'navigation.members', permission: ['members', 'read'] },
|
||||
{ to: '/diary', icon: '📝', labelKey: 'navigation.diary', permission: ['diary', 'read'] },
|
||||
{
|
||||
to: '/calendar',
|
||||
icon: '📆',
|
||||
label: 'Kalender',
|
||||
anyPermission: [
|
||||
['diary', 'read'],
|
||||
['schedule', 'read'],
|
||||
['tournaments', 'read'],
|
||||
],
|
||||
},
|
||||
{ to: '/pending-approvals', icon: '⏳', labelKey: 'navigation.approvals', capability: 'approvals' },
|
||||
{ to: '/training-stats', icon: '📊', labelKey: 'navigation.statistics', permission: ['statistics', 'read'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'competitions',
|
||||
titleKey: 'navigation.competitions',
|
||||
items: [
|
||||
{ to: '/tournaments', icon: '🏆', labelKey: 'navigation.clubTournaments', permission: ['tournaments', 'read'] },
|
||||
{ to: '/tournament-participations', icon: '📋', labelKey: 'navigation.tournamentParticipations', permission: ['tournaments', 'read'] },
|
||||
{ to: '/schedule', icon: '📅', labelKey: 'navigation.schedule', permission: ['schedule', 'read'] },
|
||||
{ to: '/friendly-matches', icon: '🤝', label: 'Freundschaftsspiele', permission: ['schedule', 'read'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
titleKey: 'navigation.settings',
|
||||
items: [
|
||||
{ to: '/club-settings', icon: '🏛️', labelKey: 'navigation.clubSettings', capability: 'admin' },
|
||||
{ to: '/predefined-activities', icon: '🎯', labelKey: 'navigation.predefinedActivities', permission: ['predefined_activities', 'read'] },
|
||||
{ to: '/team-management', icon: '🧩', labelKey: 'navigation.teamManagement', permission: ['teams', 'read'] },
|
||||
{ to: '/billing', icon: '🧾', labelKey: 'navigation.billing', permission: ['members', 'read'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
[PRODUCT_CLUB]: CLUB_MENU_SECTIONS,
|
||||
[PRODUCT_PLAYER]: [
|
||||
{
|
||||
id: 'player-area',
|
||||
title: 'Mein Bereich',
|
||||
items: [
|
||||
{ to: '/calendar', icon: '📆', label: 'Kalender' },
|
||||
{ to: '/mytischtennis-account', icon: '🔗', labelKey: 'navigation.myTischtennisAccount' },
|
||||
{ to: '/clicktt-account', icon: '🏓', labelKey: 'navigation.clickTtAccount' },
|
||||
{ to: '/orders', icon: '📦', labelKey: 'navigation.orders' },
|
||||
{ to: '/personal-settings', icon: '⚙️', labelKey: 'navigation.personalSettings' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function normalizeProduct(product) {
|
||||
if (product === PRODUCT_PLAYER) return PRODUCT_PLAYER;
|
||||
if (product === PRODUCT_CLUB) return PRODUCT_CLUB;
|
||||
return PRODUCT_TRAINER;
|
||||
}
|
||||
|
||||
export function resolveProductFromHostname(hostname = '') {
|
||||
const normalizedHostname = String(hostname || '').toLowerCase();
|
||||
|
||||
if (PRODUCT_HOSTS[PRODUCT_TRAINER].includes(normalizedHostname)) {
|
||||
return PRODUCT_TRAINER;
|
||||
}
|
||||
|
||||
if (PRODUCT_HOSTS[PRODUCT_PLAYER].includes(normalizedHostname)) {
|
||||
return PRODUCT_PLAYER;
|
||||
}
|
||||
|
||||
if (PRODUCT_HOSTS[PRODUCT_CLUB].includes(normalizedHostname)) {
|
||||
return PRODUCT_CLUB;
|
||||
}
|
||||
|
||||
return PRODUCT_TRAINER;
|
||||
}
|
||||
|
||||
export function resolveCurrentProduct() {
|
||||
const override = normalizeProduct(import.meta.env.VITE_APP_PRODUCT);
|
||||
if (import.meta.env.VITE_APP_PRODUCT) {
|
||||
return override;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return PRODUCT_TRAINER;
|
||||
}
|
||||
|
||||
return resolveProductFromHostname(window.location.hostname);
|
||||
}
|
||||
|
||||
export function getProductConfig(product = resolveCurrentProduct()) {
|
||||
return PRODUCT_CONFIGS[normalizeProduct(product)];
|
||||
}
|
||||
|
||||
export function getDefaultHomeRoute(product = resolveCurrentProduct()) {
|
||||
return getProductConfig(product).defaultHomeRoute;
|
||||
}
|
||||
|
||||
export function isProductRouteAllowed(product, routeProducts = []) {
|
||||
if (!Array.isArray(routeProducts) || routeProducts.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return routeProducts.includes(normalizeProduct(product));
|
||||
}
|
||||
|
||||
export function getCurrentBrandName() {
|
||||
return getProductConfig(resolveCurrentProduct()).brandName;
|
||||
}
|
||||
@@ -903,6 +903,9 @@
|
||||
"durationExampleShort": "z.B. 2x7",
|
||||
"showImage": "Bild/Zeichnung anzeigen",
|
||||
"participants": "Teilnehmer",
|
||||
"excusedParticipants": "Entschuldigt",
|
||||
"availableParticipants": "Anwesend möglich",
|
||||
"activeMembers": "Aktive Mitglieder",
|
||||
"searchParticipants": "Teilnehmer suchen",
|
||||
"filterAll": "Alle",
|
||||
"filterPresent": "Anwesend",
|
||||
|
||||
@@ -652,6 +652,9 @@
|
||||
"durationExampleShort": "z.B. 2x7",
|
||||
"showImage": "Bild/Zeichnung anzeigen",
|
||||
"participants": "Teilnehmer",
|
||||
"excusedParticipants": "Entschuldigt",
|
||||
"availableParticipants": "Anwesend möglich",
|
||||
"activeMembers": "Aktive Mitglieder",
|
||||
"searchParticipants": "Teilnehmer suchen",
|
||||
"filterAll": "Alle",
|
||||
"filterPresent": "Anwesend",
|
||||
|
||||
@@ -339,6 +339,24 @@
|
||||
"noGroupsAssigned": "Keine Gruppen zugeordnet",
|
||||
"noGroupsAvailable": "Keine Gruppen verfügbar",
|
||||
"addGroup": "Gruppe hinzufügen...",
|
||||
"bankAccountSection": "Bankkonto / SEPA",
|
||||
"bankAccountLoading": "Bankdaten werden geladen...",
|
||||
"accountHolder": "Kontoinhaber",
|
||||
"iban": "IBAN",
|
||||
"bic": "BIC",
|
||||
"mandateReference": "Mandatsreferenz",
|
||||
"signedOn": "Unterschrieben am",
|
||||
"validFrom": "Gültig ab",
|
||||
"bankAccountStatus": "Status",
|
||||
"bankAccountStatusActive": "Aktiv",
|
||||
"bankAccountStatusPending": "Ausstehend",
|
||||
"bankAccountStatusRevoked": "Widerrufen",
|
||||
"bankAccountNote": "Hinweis",
|
||||
"saveBankAccount": "Bankkonto speichern",
|
||||
"bankAccountSaved": "Bankkonto erfolgreich gespeichert.",
|
||||
"bankAccountLoadError": "Bankkonto konnte nicht geladen werden.",
|
||||
"bankAccountSaveError": "Bankkonto konnte nicht gespeichert werden.",
|
||||
"bankAccountMissingAfterSave": "Das Bankkonto wurde nach dem Speichern nicht wiedergefunden.",
|
||||
"remove": "Entfernen",
|
||||
"image": "Bild",
|
||||
"selectFile": "Datei auswählen",
|
||||
@@ -723,6 +741,9 @@
|
||||
"durationExampleShort": "z.B. 2x7",
|
||||
"showImage": "Bild/Zeichnung anzeigen",
|
||||
"participants": "Teilnehmer",
|
||||
"excusedParticipants": "Entschuldigt",
|
||||
"availableParticipants": "Anwesend möglich",
|
||||
"activeMembers": "Aktive Mitglieder",
|
||||
"searchParticipants": "Teilnehmer suchen",
|
||||
"filterAll": "Alle",
|
||||
"filterPresent": "Anwesend",
|
||||
|
||||
@@ -333,6 +333,24 @@
|
||||
"noGroupsAssigned": "No groups assigned",
|
||||
"noGroupsAvailable": "No groups available",
|
||||
"addGroup": "Add group...",
|
||||
"bankAccountSection": "Bank account / SEPA",
|
||||
"bankAccountLoading": "Loading bank details...",
|
||||
"accountHolder": "Account holder",
|
||||
"iban": "IBAN",
|
||||
"bic": "BIC",
|
||||
"mandateReference": "Mandate reference",
|
||||
"signedOn": "Signed on",
|
||||
"validFrom": "Valid from",
|
||||
"bankAccountStatus": "Status",
|
||||
"bankAccountStatusActive": "Active",
|
||||
"bankAccountStatusPending": "Pending",
|
||||
"bankAccountStatusRevoked": "Revoked",
|
||||
"bankAccountNote": "Note",
|
||||
"saveBankAccount": "Save bank account",
|
||||
"bankAccountSaved": "Bank account saved successfully.",
|
||||
"bankAccountLoadError": "Bank account could not be loaded.",
|
||||
"bankAccountSaveError": "Bank account could not be saved.",
|
||||
"bankAccountMissingAfterSave": "The bank account could not be found again after saving.",
|
||||
"remove": "Remove",
|
||||
"image": "Image",
|
||||
"selectFile": "Select file",
|
||||
|
||||
@@ -714,6 +714,9 @@
|
||||
"durationExampleShort": "例如:2x7",
|
||||
"showImage": "显示图片/图示",
|
||||
"participants": "参与者",
|
||||
"excusedParticipants": "已请假",
|
||||
"availableParticipants": "可到场",
|
||||
"activeMembers": "活跃成员",
|
||||
"searchParticipants": "搜索参与者",
|
||||
"filterAll": "全部",
|
||||
"filterPresent": "出席",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { applySeoForPath } from './utils/seo.js';
|
||||
import { safeSessionStorage } from './utils/storage.js';
|
||||
import { safeLocalStorage, safeSessionStorage } from './utils/storage.js';
|
||||
import { getDefaultHomeRoute, isProductRouteAllowed, resolveCurrentProduct } from './config/products.js';
|
||||
import { CLUB_CONCEPT_ROUTES } from './config/clubWorkspace.js';
|
||||
|
||||
const Register = () => import('./views/Register.vue');
|
||||
const Login = () => import('./views/Login.vue');
|
||||
@@ -34,47 +36,182 @@ const MemberTransferSettingsView = () => import('./views/MemberTransferSettingsV
|
||||
const PersonalSettings = () => import('./views/PersonalSettings.vue');
|
||||
const OrdersView = () => import('./views/OrdersView.vue');
|
||||
const BillingView = () => import('./views/BillingView.vue');
|
||||
const ClubRequestsView = () => import('./views/ClubRequestsView.vue');
|
||||
const ClubTasksView = () => import('./views/ClubTasksView.vue');
|
||||
const ClubHistoryView = () => import('./views/ClubHistoryView.vue');
|
||||
const ClubStatisticsView = () => import('./views/ClubStatisticsView.vue');
|
||||
const ClubArchiveView = () => import('./views/ClubArchiveView.vue');
|
||||
const ClubAccountsView = () => import('./views/ClubAccountsView.vue');
|
||||
const ClubInvoicesView = () => import('./views/ClubInvoicesView.vue');
|
||||
const ClubCommunicationView = () => import('./views/ClubCommunicationView.vue');
|
||||
const ClubOperationsWorkspaceView = () => import('./views/ClubOperationsWorkspaceView.vue');
|
||||
const ClubConceptModuleView = () => import('./views/ClubConceptModuleView.vue');
|
||||
const Impressum = () => import('./views/Impressum.vue');
|
||||
const Datenschutz = () => import('./views/Datenschutz.vue');
|
||||
const KontoLoeschen = () => import('./views/KontoLoeschen.vue');
|
||||
|
||||
function withMeta(meta = {}) {
|
||||
return meta;
|
||||
}
|
||||
|
||||
function getStoredCurrentClubPermissions() {
|
||||
const currentClub = safeSessionStorage.getItem('currentClub');
|
||||
if (!currentClub) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const permissionMap = JSON.parse(safeLocalStorage.getItem('clubPermissions') || '{}');
|
||||
return permissionMap[currentClub] || null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasRoutePermission(resource, action) {
|
||||
const permissions = getStoredCurrentClubPermissions();
|
||||
if (!permissions) {
|
||||
return false;
|
||||
}
|
||||
if (permissions.isOwner) {
|
||||
return true;
|
||||
}
|
||||
if (resource === 'mytischtennis') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return permissions.permissions?.[resource]?.[action] === true;
|
||||
}
|
||||
|
||||
function hasRouteCapability(capability) {
|
||||
const permissions = getStoredCurrentClubPermissions();
|
||||
if (!permissions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (permissions.isOwner || permissions.isAdmin || permissions.role === 'admin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (capability === 'approvals') {
|
||||
return hasRoutePermission('approvals', 'read');
|
||||
}
|
||||
if (capability === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isRouteAuthorized(to) {
|
||||
const protectedRules = to.matched
|
||||
.map((record) => record.meta || {})
|
||||
.filter((meta) => meta.permission || meta.capability || Array.isArray(meta.anyPermission));
|
||||
|
||||
if (protectedRules.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return protectedRules.every((meta) => {
|
||||
if (meta.capability && !hasRouteCapability(meta.capability)) {
|
||||
return false;
|
||||
}
|
||||
if (meta.permission) {
|
||||
const [resource, action] = meta.permission;
|
||||
if (!hasRoutePermission(resource, action)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(meta.anyPermission) && !meta.anyPermission.some(([resource, action]) => hasRoutePermission(resource, action))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const trainerOnly = ['trainer'];
|
||||
const clubOnly = ['club'];
|
||||
const fullAppProducts = ['trainer', 'club'];
|
||||
const allProducts = ['trainer', 'club', 'player'];
|
||||
|
||||
const conceptRoutes = CLUB_CONCEPT_ROUTES
|
||||
.filter((route) => route.path !== '/club-requests')
|
||||
.filter((route) => route.path !== '/club-tasks')
|
||||
.filter((route) => route.path !== '/club-users')
|
||||
.filter((route) => route.path !== '/club-roles')
|
||||
.filter((route) => route.path !== '/club-history')
|
||||
.filter((route) => route.path !== '/club-statistics')
|
||||
.filter((route) => route.path !== '/club-archive')
|
||||
.filter((route) => route.path !== '/club-accounts')
|
||||
.filter((route) => route.path !== '/club-invoices')
|
||||
.filter((route) => route.path !== '/club-communication')
|
||||
.filter((route) => route.path !== '/club-documents')
|
||||
.filter((route) => route.path !== '/club-events')
|
||||
.filter((route) => route.path !== '/club-sponsors')
|
||||
.filter((route) => route.path !== '/club-fees')
|
||||
.filter((route) => route.path !== '/club-payments')
|
||||
.filter((route) => route.path !== '/club-reports')
|
||||
.map((route) => ({
|
||||
path: route.path,
|
||||
name: route.name,
|
||||
component: ClubConceptModuleView,
|
||||
meta: withMeta({ products: clubOnly, moduleMeta: route }),
|
||||
}));
|
||||
|
||||
const routes = [
|
||||
{ path: '/register', name: 'register', component: Register, meta: { public: true } },
|
||||
{ path: '/login', name: 'login', component: Login, meta: { public: true } },
|
||||
{ path: '/activate/:activationCode', name: 'activate', component: Activate, meta: { public: true } },
|
||||
{ path: '/forgot-password', name: 'forgot-password', component: ForgotPassword, meta: { public: true } },
|
||||
{ path: '/reset-password/:token', name: 'reset-password', component: ResetPassword, meta: { public: true } },
|
||||
{ path: '/', name: 'home', component: Home, meta: { public: true } },
|
||||
{ path: '/vereinssoftware-tischtennis', name: 'club-software-seo', component: TableTennisClubSoftware, meta: { public: true } },
|
||||
{ path: '/mitgliederverwaltung-verein', name: 'member-management-seo', component: ClubMemberManagementPage, meta: { public: true } },
|
||||
{ path: '/trainingsplanung-tischtennis', name: 'training-planning-seo', component: TrainingPlanningPage, meta: { public: true } },
|
||||
{ path: '/turniersoftware-tischtennis', name: 'tournament-software-seo', component: TableTennisTournamentSoftwarePage, meta: { public: true } },
|
||||
{ path: '/createclub', name: 'create-club', component: CreateClub },
|
||||
{ path: '/showclub/:clubId', name: 'show-club', component: ClubView },
|
||||
{ path: '/members', name: 'members', component: MembersView },
|
||||
{ path: '/diary', name: 'diary', component: DiaryView },
|
||||
{ path: '/calendar', name: 'calendar', component: CalendarView },
|
||||
{ path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView},
|
||||
{ path: '/schedule', name: 'schedule', component: ScheduleView},
|
||||
{ path: '/friendly-matches', name: 'friendly-matches', component: ScheduleView, props: { friendlyOnly: true } },
|
||||
{ path: '/tournaments', name: 'tournaments', component: TournamentsView },
|
||||
{ path: '/tournament-participations', name: 'tournament-participations', component: OfficialTournaments },
|
||||
{ path: '/training-stats', name: 'training-stats', component: TrainingStatsView },
|
||||
{ path: '/club-settings', name: 'club-settings', component: ClubSettings },
|
||||
{ path: '/predefined-activities', name: 'predefined-activities', component: PredefinedActivities },
|
||||
{ path: '/mytischtennis-account', name: 'mytischtennis-account', component: MyTischtennisAccount },
|
||||
{ path: '/clicktt-account', name: 'clicktt-account', component: ClickTtAccount },
|
||||
{ path: '/team-management', name: 'team-management', component: TeamManagementView },
|
||||
{ path: '/permissions', name: 'permissions', component: PermissionsView },
|
||||
{ path: '/logs', name: 'logs', component: LogsView },
|
||||
{ path: '/clicktt', name: 'clicktt', component: ClickTtView },
|
||||
{ path: '/member-transfer-settings', name: 'member-transfer-settings', component: MemberTransferSettingsView },
|
||||
{ path: '/personal-settings', name: 'personal-settings', component: PersonalSettings },
|
||||
{ path: '/orders', name: 'orders', component: OrdersView },
|
||||
{ path: '/billing', name: 'billing', component: BillingView },
|
||||
{ path: '/impressum', name: 'impressum', component: Impressum, meta: { public: true } },
|
||||
{ path: '/datenschutz', name: 'datenschutz', component: Datenschutz, meta: { public: true } },
|
||||
{ path: '/konto-loeschen', name: 'konto-loeschen', component: KontoLoeschen, meta: { public: true } },
|
||||
{ path: '/register', name: 'register', component: Register, meta: withMeta({ public: true, products: allProducts }) },
|
||||
{ path: '/login', name: 'login', component: Login, meta: withMeta({ public: true, products: allProducts }) },
|
||||
{ path: '/activate/:activationCode', name: 'activate', component: Activate, meta: withMeta({ public: true, products: allProducts }) },
|
||||
{ path: '/forgot-password', name: 'forgot-password', component: ForgotPassword, meta: withMeta({ public: true, products: allProducts }) },
|
||||
{ path: '/reset-password/:token', name: 'reset-password', component: ResetPassword, meta: withMeta({ public: true, products: allProducts }) },
|
||||
{ path: '/', name: 'home', component: Home, meta: withMeta({ public: true, products: allProducts }) },
|
||||
{ path: '/vereinssoftware-tischtennis', name: 'club-software-seo', component: TableTennisClubSoftware, meta: withMeta({ public: true, products: fullAppProducts }) },
|
||||
{ path: '/mitgliederverwaltung-verein', name: 'member-management-seo', component: ClubMemberManagementPage, meta: withMeta({ public: true, products: fullAppProducts }) },
|
||||
{ path: '/trainingsplanung-tischtennis', name: 'training-planning-seo', component: TrainingPlanningPage, meta: withMeta({ public: true, products: fullAppProducts }) },
|
||||
{ path: '/turniersoftware-tischtennis', name: 'tournament-software-seo', component: TableTennisTournamentSoftwarePage, meta: withMeta({ public: true, products: fullAppProducts }) },
|
||||
{ path: '/createclub', name: 'create-club', component: CreateClub, meta: withMeta({ products: fullAppProducts }) },
|
||||
{ path: '/showclub/:clubId', name: 'show-club', component: ClubView, meta: withMeta({ products: fullAppProducts }) },
|
||||
{ path: '/members', name: 'members', component: MembersView, meta: withMeta({ products: fullAppProducts, permission: ['members', 'read'] }) },
|
||||
{ path: '/diary', name: 'diary', component: DiaryView, meta: withMeta({ products: trainerOnly }) },
|
||||
{ path: '/calendar', name: 'calendar', component: CalendarView, meta: withMeta({ products: allProducts, anyPermission: [['diary', 'read'], ['schedule', 'read'], ['tournaments', 'read']] }) },
|
||||
{ path: '/pending-approvals', name: 'pending-approvals', component: PendingApprovalsView, meta: withMeta({ products: fullAppProducts, capability: 'approvals' }) },
|
||||
{ path: '/schedule', name: 'schedule', component: ScheduleView, meta: withMeta({ products: fullAppProducts, permission: ['schedule', 'read'] }) },
|
||||
{ path: '/friendly-matches', name: 'friendly-matches', component: ScheduleView, props: { friendlyOnly: true }, meta: withMeta({ products: fullAppProducts }) },
|
||||
{ path: '/tournaments', name: 'tournaments', component: TournamentsView, meta: withMeta({ products: fullAppProducts, permission: ['tournaments', 'read'] }) },
|
||||
{ path: '/tournament-participations', name: 'tournament-participations', component: OfficialTournaments, meta: withMeta({ products: fullAppProducts, permission: ['tournaments', 'read'] }) },
|
||||
{ path: '/training-stats', name: 'training-stats', component: TrainingStatsView, meta: withMeta({ products: fullAppProducts, permission: ['statistics', 'read'] }) },
|
||||
{ path: '/club-settings', name: 'club-settings', component: ClubSettings, meta: withMeta({ products: fullAppProducts, capability: 'admin' }) },
|
||||
{ path: '/predefined-activities', name: 'predefined-activities', component: PredefinedActivities, meta: withMeta({ products: fullAppProducts, permission: ['predefined_activities', 'read'] }) },
|
||||
{ path: '/mytischtennis-account', name: 'mytischtennis-account', component: MyTischtennisAccount, meta: withMeta({ products: allProducts }) },
|
||||
{ path: '/clicktt-account', name: 'clicktt-account', component: ClickTtAccount, meta: withMeta({ products: allProducts }) },
|
||||
{ path: '/team-management', name: 'team-management', component: TeamManagementView, meta: withMeta({ products: fullAppProducts, permission: ['teams', 'read'] }) },
|
||||
{ path: '/permissions', name: 'permissions', component: PermissionsView, meta: withMeta({ products: fullAppProducts, permission: ['permissions', 'read'] }) },
|
||||
{ path: '/club-users', name: 'club-users', component: PermissionsView, props: { viewMode: 'users' }, meta: withMeta({ products: clubOnly, permission: ['permissions', 'read'] }) },
|
||||
{ path: '/club-roles', name: 'club-roles', component: PermissionsView, props: { viewMode: 'roles' }, meta: withMeta({ products: clubOnly, permission: ['permissions', 'read'] }) },
|
||||
{ path: '/logs', name: 'logs', component: LogsView, meta: withMeta({ products: fullAppProducts }) },
|
||||
{ path: '/clicktt', name: 'clicktt', component: ClickTtView, meta: withMeta({ products: fullAppProducts }) },
|
||||
{ path: '/member-transfer-settings', name: 'member-transfer-settings', component: MemberTransferSettingsView, meta: withMeta({ products: fullAppProducts }) },
|
||||
{ path: '/personal-settings', name: 'personal-settings', component: PersonalSettings, meta: withMeta({ products: allProducts }) },
|
||||
{ path: '/orders', name: 'orders', component: OrdersView, meta: withMeta({ products: allProducts }) },
|
||||
{ path: '/billing', name: 'billing', component: BillingView, meta: withMeta({ products: trainerOnly }) },
|
||||
{ path: '/club-requests', name: 'club-requests', component: ClubRequestsView, meta: withMeta({ products: clubOnly, permission: ['requests', 'read'] }) },
|
||||
{ path: '/club-tasks', name: 'club-tasks', component: ClubTasksView, meta: withMeta({ products: clubOnly, permission: ['tasks', 'read'] }) },
|
||||
{ path: '/club-history', name: 'club-history', component: ClubHistoryView, meta: withMeta({ products: clubOnly, permission: ['history', 'read'] }) },
|
||||
{ path: '/club-statistics', name: 'club-statistics', component: ClubStatisticsView, meta: withMeta({ products: clubOnly, permission: ['statistics', 'read'] }) },
|
||||
{ path: '/club-archive', name: 'club-archive', component: ClubArchiveView, meta: withMeta({ products: clubOnly, permission: ['archive', 'read'] }) },
|
||||
{ path: '/club-accounts', name: 'club-accounts', component: ClubAccountsView, meta: withMeta({ products: clubOnly, permission: ['finance_accounts', 'read'] }) },
|
||||
{ path: '/club-invoices', name: 'club-invoices', component: ClubInvoicesView, meta: withMeta({ products: clubOnly, permission: ['finance_invoices', 'read'] }) },
|
||||
{ path: '/club-communication', name: 'club-communication', component: ClubCommunicationView, meta: withMeta({ products: clubOnly, permission: ['communication', 'read'] }) },
|
||||
{ path: '/club-documents', name: 'club-documents', component: ClubOperationsWorkspaceView, props: { moduleKey: 'documents' }, meta: withMeta({ products: clubOnly, permission: ['settings', 'read'] }) },
|
||||
{ path: '/club-events', name: 'club-events', component: ClubOperationsWorkspaceView, props: { moduleKey: 'events' }, meta: withMeta({ products: clubOnly, permission: ['schedule', 'read'] }) },
|
||||
{ path: '/club-sponsors', name: 'club-sponsors', component: ClubOperationsWorkspaceView, props: { moduleKey: 'sponsors' }, meta: withMeta({ products: clubOnly, permission: ['settings', 'read'] }) },
|
||||
{ path: '/club-fees', name: 'club-fees', component: ClubOperationsWorkspaceView, props: { moduleKey: 'fees' }, meta: withMeta({ products: clubOnly, permission: ['finance_invoices', 'write'] }) },
|
||||
{ path: '/club-payments', name: 'club-payments', component: ClubOperationsWorkspaceView, props: { moduleKey: 'payments' }, meta: withMeta({ products: clubOnly, permission: ['finance_accounts', 'read'] }) },
|
||||
{ path: '/club-reports', name: 'club-reports', component: ClubOperationsWorkspaceView, props: { moduleKey: 'reports' }, meta: withMeta({ products: clubOnly, permission: ['statistics', 'read'] }) },
|
||||
...conceptRoutes,
|
||||
{ path: '/impressum', name: 'impressum', component: Impressum, meta: withMeta({ public: true, products: allProducts }) },
|
||||
{ path: '/datenschutz', name: 'datenschutz', component: Datenschutz, meta: withMeta({ public: true, products: allProducts }) },
|
||||
{ path: '/konto-loeschen', name: 'konto-loeschen', component: KontoLoeschen, meta: withMeta({ public: true, products: allProducts }) },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
@@ -83,8 +220,16 @@ const router = createRouter({
|
||||
});
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const currentProduct = resolveCurrentProduct();
|
||||
const defaultHomeRoute = getDefaultHomeRoute(currentProduct);
|
||||
const isAuthenticated = Boolean(safeSessionStorage.getItem('token'));
|
||||
const isPublicRoute = to.matched.some((record) => record.meta?.public);
|
||||
const routeProducts = to.matched.flatMap((record) => record.meta?.products || []);
|
||||
|
||||
if (!isProductRouteAllowed(currentProduct, routeProducts)) {
|
||||
next(defaultHomeRoute);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAuthenticated && !isPublicRoute) {
|
||||
next({
|
||||
@@ -95,7 +240,12 @@ router.beforeEach((to, from, next) => {
|
||||
}
|
||||
|
||||
if (isAuthenticated && (to.path === '/login' || to.path === '/register')) {
|
||||
next('/');
|
||||
next(defaultHomeRoute);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAuthenticated && !isPublicRoute && !isRouteAuthorized(to)) {
|
||||
next(defaultHomeRoute);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import router from './router.js';
|
||||
import apiClient from './apiClient.js';
|
||||
import { safeSessionStorage, safeLocalStorage } from './utils/storage.js';
|
||||
import i18n from './i18n';
|
||||
import { getProductConfig, resolveCurrentProduct } from './config/products.js';
|
||||
|
||||
const initialProduct = resolveCurrentProduct();
|
||||
const initialProductConfig = getProductConfig(initialProduct);
|
||||
|
||||
const store = createStore({
|
||||
state: {
|
||||
@@ -43,6 +47,9 @@ const store = createStore({
|
||||
// Browser-Sprache wird in i18n/index.js erkannt
|
||||
return null;
|
||||
})(),
|
||||
appProduct: initialProduct,
|
||||
appBrand: initialProductConfig.brandName,
|
||||
defaultHomeRoute: initialProductConfig.defaultHomeRoute,
|
||||
},
|
||||
mutations: {
|
||||
setToken(state, token) {
|
||||
@@ -97,6 +104,12 @@ const store = createStore({
|
||||
state.language = language;
|
||||
safeLocalStorage.setItem('userLanguage', language);
|
||||
},
|
||||
setAppProduct(state, product) {
|
||||
const config = getProductConfig(product);
|
||||
state.appProduct = config.id;
|
||||
state.appBrand = config.brandName;
|
||||
state.defaultHomeRoute = config.defaultHomeRoute;
|
||||
},
|
||||
clearToken(state) {
|
||||
state.token = null;
|
||||
safeSessionStorage.removeItem('token');
|
||||
@@ -200,7 +213,9 @@ const store = createStore({
|
||||
const data = response.data || {};
|
||||
const normalized = {
|
||||
role: data.role ?? 'member',
|
||||
roles: Array.isArray(data.roles) ? data.roles : [],
|
||||
isOwner: data.isOwner ?? false,
|
||||
isAdmin: data.isAdmin ?? (data.role === 'admin'),
|
||||
permissions: data.permissions ?? {}
|
||||
};
|
||||
commit('setPermissions', { clubId, permissions: normalized });
|
||||
@@ -211,7 +226,9 @@ const store = createStore({
|
||||
clubId,
|
||||
permissions: {
|
||||
role: 'member',
|
||||
roles: [],
|
||||
isOwner: false,
|
||||
isAdmin: false,
|
||||
permissions: {}
|
||||
}
|
||||
});
|
||||
@@ -256,6 +273,9 @@ const store = createStore({
|
||||
clubs: state => state.clubs,
|
||||
sidebarCollapsed: state => state.sidebarCollapsed,
|
||||
language: state => state.language,
|
||||
appProduct: state => state.appProduct,
|
||||
appBrand: state => state.appBrand,
|
||||
defaultHomeRoute: state => state.defaultHomeRoute,
|
||||
currentClubName: state => {
|
||||
const club = state.clubs.find(club => club.id === parseInt(state.currentClub));
|
||||
return club ? club.name : '';
|
||||
@@ -284,7 +304,18 @@ const store = createStore({
|
||||
userRole: state => {
|
||||
if (!state.currentClub) return null;
|
||||
const perms = state.permissions[state.currentClub];
|
||||
return perms?.role || null; // null wenn nicht geladen, nicht 'member'
|
||||
if (perms?.isAdmin) return 'admin';
|
||||
return perms?.role || null;
|
||||
},
|
||||
userRoles: state => {
|
||||
if (!state.currentClub) return [];
|
||||
const perms = state.permissions[state.currentClub];
|
||||
return Array.isArray(perms?.roles) ? perms.roles : [];
|
||||
},
|
||||
isAdminRole: state => {
|
||||
if (!state.currentClub) return false;
|
||||
const perms = state.permissions[state.currentClub];
|
||||
return perms?.isAdmin || false;
|
||||
},
|
||||
// Dialog-Getters
|
||||
dialogs: state => state.dialogs,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user