feat(MemberService, SchedulerService): implement inactive member cleanup functionality

- Added a new method in MemberService to clean up inactive members who haven't trained in the last specified days, enhancing data management.
- Integrated the cleanup method into SchedulerService to run automatically via a scheduled job, improving system maintenance.
- Updated the MembersView to reflect changes in member data, ensuring a consistent user experience.
This commit is contained in:
Torsten Schulz (local)
2026-03-18 18:03:15 +01:00
parent eea3372057
commit 9340ee3509
3 changed files with 318 additions and 103 deletions

View File

@@ -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, '&quot;')
.replace(/'/g, '&#39;');
}
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();

View File

@@ -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();