diff --git a/backend/services/memberService.js b/backend/services/memberService.js index 0beb5a5a..e9e80987 100644 --- a/backend/services/memberService.js +++ b/backend/services/memberService.js @@ -6,6 +6,7 @@ import MemberImage from "../models/MemberImage.js"; import MemberTtrHistory from "../models/MemberTtrHistory.js"; import Participant from "../models/Participant.js"; import DiaryDate from "../models/DiaryDates.js"; +import { Op, fn, col } from 'sequelize'; import path from 'path'; import fs from 'fs'; import sharp from 'sharp'; @@ -1709,6 +1710,80 @@ class MemberService { } } + async cleanupInactiveMembersWithoutRecentTraining(options = {}) { + const { clubId = null, inactiveDays = 365 } = options; + const cutoffDate = new Date(); + cutoffDate.setHours(0, 0, 0, 0); + cutoffDate.setDate(cutoffDate.getDate() - inactiveDays); + + const diaryDateWhere = {}; + if (clubId) { + diaryDateWhere.clubId = clubId; + } + + const lastTrainingRows = await Participant.findAll({ + attributes: [ + 'memberId', + [fn('MAX', col('diaryDate.date')), 'lastTrainingDate'] + ], + include: [ + { + model: DiaryDate, + as: 'diaryDate', + attributes: [], + required: true, + where: diaryDateWhere + } + ], + where: { + attendanceStatus: 'present' + }, + group: ['memberId'], + raw: true + }); + + const staleMemberIds = lastTrainingRows + .filter((row) => { + const lastTrainingDate = row.lastTrainingDate ? new Date(row.lastTrainingDate) : null; + return lastTrainingDate && !Number.isNaN(lastTrainingDate.getTime()) && lastTrainingDate < cutoffDate; + }) + .map((row) => row.memberId); + + if (staleMemberIds.length === 0) { + return { + success: true, + deletedCount: 0, + deletedMembers: [] + }; + } + + const memberWhere = { + id: { [Op.in]: staleMemberIds }, + active: false + }; + + if (clubId) { + memberWhere.clubId = clubId; + } + + const membersToDelete = await Member.findAll({ + where: memberWhere + }); + + const deletedMembers = []; + + for (const member of membersToDelete) { + const result = await this._deleteMemberWithRelations(member); + deletedMembers.push(result); + } + + return { + success: true, + deletedCount: deletedMembers.length, + deletedMembers + }; + } + _selectLatestImage(images) { if (!Array.isArray(images) || images.length === 0) { return null; @@ -1786,6 +1861,91 @@ class MemberService { .replace(/\"/g, '"') .replace(/'/g, '''); } + + async _deleteMemberWithRelations(member) { + const sequelize = Member.sequelize; + const transaction = await sequelize.transaction(); + + try { + const { + default: DiaryMemberActivity + } = await import('../models/DiaryMemberActivity.js'); + const { + default: DiaryMemberNote + } = await import('../models/DiaryMemberNote.js'); + const { + default: DiaryMemberTag + } = await import('../models/DiaryMemberTag.js'); + const { + default: DiaryNote + } = await import('../models/DiaryNote.js'); + const { + default: MemberNote + } = await import('../models/MemberNote.js'); + const { + default: MemberContact + } = await import('../models/MemberContact.js'); + const { + default: MemberTrainingGroup + } = await import('../models/MemberTrainingGroup.js'); + const { + default: TournamentMember + } = await import('../models/TournamentMember.js'); + const { + default: OfficialCompetitionMember + } = await import('../models/OfficialCompetitionMember.js'); + const { + default: Accident + } = await import('../models/Accident.js'); + + const participantIds = (await Participant.findAll({ + where: { memberId: member.id }, + attributes: ['id'], + raw: true, + transaction + })).map((participant) => participant.id); + + if (participantIds.length > 0) { + await DiaryMemberActivity.destroy({ + where: { participantId: { [Op.in]: participantIds } }, + transaction + }); + } + + await DiaryMemberNote.destroy({ where: { memberId: member.id }, transaction }); + await DiaryMemberTag.destroy({ where: { memberId: member.id }, transaction }); + await DiaryNote.destroy({ where: { memberId: member.id }, transaction }); + await MemberNote.destroy({ where: { memberId: member.id }, transaction }); + await MemberTtrHistory.destroy({ where: { memberId: member.id }, transaction }); + await MemberContact.destroy({ where: { memberId: member.id }, transaction }); + await MemberTrainingGroup.destroy({ where: { memberId: member.id }, transaction }); + await OfficialCompetitionMember.destroy({ where: { memberId: member.id }, transaction }); + await Accident.destroy({ where: { memberId: member.id }, transaction }); + await TournamentMember.destroy({ where: { clubMemberId: member.id }, transaction }); + await Participant.destroy({ where: { memberId: member.id }, transaction }); + await MemberImage.destroy({ where: { memberId: member.id }, transaction }); + await Member.destroy({ where: { id: member.id }, transaction }); + + await transaction.commit(); + + const imageDirectory = path.join('images', 'members', String(member.id)); + try { + await fs.promises.rm(imageDirectory, { recursive: true, force: true }); + } catch (fileError) { + console.warn('[cleanupInactiveMembersWithoutRecentTraining] - Failed to remove image directory:', fileError.message); + } + + return { + id: member.id, + clubId: member.clubId, + firstName: member.firstName, + lastName: member.lastName + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } } export default new MemberService(); diff --git a/backend/services/schedulerService.js b/backend/services/schedulerService.js index cba6fe60..a428269f 100644 --- a/backend/services/schedulerService.js +++ b/backend/services/schedulerService.js @@ -2,6 +2,7 @@ import cron from 'node-cron'; import autoUpdateRatingsService from './autoUpdateRatingsService.js'; import autoFetchMatchResultsService from './autoFetchMatchResultsService.js'; import apiLogService from './apiLogService.js'; +import memberService from './memberService.js'; import { devLog } from '../utils/logger.js'; class SchedulerService { @@ -38,6 +39,20 @@ class SchedulerService { } } + async runInactiveMemberCleanupJob(isAutomatic = true) { + const startTime = Date.now(); + try { + const result = await memberService.cleanupInactiveMembersWithoutRecentTraining({ inactiveDays: 365 }); + const executionTime = Date.now() - startTime; + await apiLogService.logSchedulerExecution('member_cleanup', true, result || { success: true }, executionTime, null); + return { success: true, result, executionTime, isAutomatic }; + } catch (error) { + const executionTime = Date.now() - startTime; + await apiLogService.logSchedulerExecution('member_cleanup', false, { success: false }, executionTime, error?.message || String(error)); + return { success: false, error: error?.message || String(error), executionTime, isAutomatic }; + } + } + /** * Start the scheduler */ @@ -65,8 +80,16 @@ class SchedulerService { timezone: 'Europe/Berlin' }); + const inactiveMemberCleanupJob = cron.schedule('45 6 * * *', async () => { + devLog('[Scheduler] Running inactive member cleanup job...'); + await this.runInactiveMemberCleanupJob(true); + }, { + timezone: 'Europe/Berlin' + }); + this.jobs.set('ratingUpdates', ratingUpdateJob); this.jobs.set('matchResults', matchResultsJob); + this.jobs.set('inactiveMemberCleanup', inactiveMemberCleanupJob); this.isRunning = true; const now = new Date(); diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index 6c451da3..a56ed185 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -54,112 +54,134 @@ {{ membersLoadError }} -
-
-
-
{{ $t('members.memberDetails') }}
-

{{ selectedMemberPreview.firstName }} {{ selectedMemberPreview.lastName }}

-
-
- - - - -
-
-
-
- {{ $t('members.status') }} -
- - {{ badge.label }} - -
-
-
- {{ $t('members.contact') }} -
{{ getFormattedPhoneNumbers(selectedMemberPreview) }}
-
{{ getFormattedEmails(selectedMemberPreview) }}
-
{{ getCompactAddress(selectedMemberPreview) || $t('members.noAddressShort') }}
-
-
- {{ $t('members.birthdate') }} -
{{ getFormattedBirthdate(selectedMemberPreview.birthDate) }}
-
-
- {{ $t('members.previewLastTraining') }} -
{{ getOptionalFormattedDate(selectedMemberPreview.lastTraining, 'members.previewNoLastTraining') }}
-
{{ $t('members.notInTrainingTooltip', { weeks: selectedMemberPreview.missedTrainingWeeks || 0 }) }}
-
-
- {{ $t('members.trainingGroups') }} -
- - {{ group.name }} - -
-
{{ $t('members.noGroupsAssigned') }}
-
-
- {{ $t('members.dataQuality') }} -
- - {{ issue.label }} - -
-
{{ $t('members.dataQualityComplete') }}
-
-
- {{ $t('members.openTasks') }} -
-
- {{ task.label }} - -
-
-
{{ $t('members.noOpenTasks') }}
-
-
-
- -
-
-
- {{ memberToEdit === null ? '+' : '✎' }} + +
+
-
{{ memberToEdit === null ? $t('members.newMember') : $t('members.editMember') }}
-
- {{ memberToEdit === null ? $t('members.editorCreateHint') : $t('members.editorEditHint', { name: `${memberToEdit.firstName} ${memberToEdit.lastName}` }) }} -
+
{{ $t('members.memberDetails') }}
+

{{ selectedMemberPreview.firstName }} {{ selectedMemberPreview.lastName }}

+
+
+ + + + +
+
+
+
+ {{ $t('members.status') }} +
+ + {{ badge.label }} + +
+
+
+ {{ $t('members.contact') }} +
{{ getFormattedPhoneNumbers(selectedMemberPreview) }}
+
{{ getFormattedEmails(selectedMemberPreview) }}
+
{{ getCompactAddress(selectedMemberPreview) || $t('members.noAddressShort') }}
+
+
+ {{ $t('members.birthdate') }} +
{{ getFormattedBirthdate(selectedMemberPreview.birthDate) }}
+
+
+ {{ $t('members.previewLastTraining') }} +
{{ getOptionalFormattedDate(selectedMemberPreview.lastTraining, 'members.previewNoLastTraining') }}
+
{{ $t('members.notInTrainingTooltip', { weeks: selectedMemberPreview.missedTrainingWeeks || 0 }) }}
+
+
+ {{ $t('members.trainingGroups') }} +
+ + {{ group.name }} + +
+
{{ $t('members.noGroupsAssigned') }}
+
+
+ {{ $t('members.dataQuality') }} +
+ + {{ issue.label }} + +
+
{{ $t('members.dataQualityComplete') }}
+
+
+ {{ $t('members.openTasks') }} +
+
+ {{ task.label }} + +
+
+
{{ $t('members.noOpenTasks') }}
-
-
+ + + + +
+
+
+ {{ memberToEdit === null ? '+' : '✎' }} +
+
{{ memberToEdit === null ? $t('members.newMember') : $t('members.editMember') }}
+
+ {{ memberToEdit === null ? $t('members.editorCreateHint') : $t('members.editorEditHint', { name: `${memberToEdit.firstName} ${memberToEdit.lastName}` }) }} +
+
+
+ +
+
@@ -268,8 +290,9 @@
+
-
+
@@ -953,6 +976,10 @@ export default { this.selectedPreviewTrainingGroups = []; } }, + closeMemberPreviewDialog() { + this.selectedMemberPreview = null; + this.selectedPreviewTrainingGroups = []; + }, async quickRemoveTestMembershipInternal(member, { silent = false } = {}) { const response = await apiClient.post(`/clubmembers/quick-update-test-membership/${this.currentClub}/${member.id}`); if (!response.data.success) { @@ -1211,6 +1238,10 @@ export default { this.resetToNewMember(); } }, + closeMemberFormDialog() { + this.memberFormIsOpen = false; + this.resetToNewMember(); + }, toggleMemberInfo() { this.showMemberInfo = !this.showMemberInfo; @@ -1482,6 +1513,7 @@ export default { }, async editMember(member) { const birthDate = member.birthDate; + this.closeMemberPreviewDialog(); this.memberToEdit = member; this.memberFormIsOpen = true; this.newFirstname = member.firstName;