ResourceCleaner.java

package access.cron;


import access.mail.MailBox;
import access.manage.Contact;
import access.model.EntityType;
import access.manage.Manage;
import access.model.Application;
import access.model.Organization;
import access.model.User;
import access.repository.OrganizationRepository;
import access.repository.UserRepository;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;

@Component
public class ResourceCleaner {

    public static final String LOCK_NAME = "resource_cleaner_user_level_lock";
    private static final Log LOG = LogFactory.getLog(ResourceCleaner.class);

    private final OrganizationRepository organizationRepository;
    private final UserRepository userRepository;
    private final MailBox mailBox;
    private final Manage manage;

    @Value("${cron.org-contact-reminder-days}")
    private int orgContactReminderDays;

    @Value("${cron.org-delete-after-days}")
    private int orgDeleteAfterDays;

    @Value("${cron.user-inactivity-warn-days}")
    private int userInactivityWarnDays;

    @Value("${cron.user-inactivity-delete-days}")
    private int userInactivityDeleteDays;

    public ResourceCleaner(OrganizationRepository organizationRepository,
                           UserRepository userRepository,
                           MailBox mailBox,
                           Manage manage) {
        this.organizationRepository = organizationRepository;
        this.userRepository = userRepository;
        this.mailBox = mailBox;
        this.manage = manage;
    }

    @Scheduled(fixedDelayString = "${cron.user-cleaner-cron}", initialDelayString = "${cron.user-cleaner-cron-initial-delay}")
    @SchedulerLock(name = LOCK_NAME, lockAtLeastFor = "${cron.user-cleaner-lock-at-least-for}",
            lockAtMostFor = "${cron.user-cleaner-lock-at-most-for}")
    @Transactional
    public void clean() {
        LOG.info("CRON: Cleaning resources");
        Map<String, Object> results = this.doClean();
        LOG.info("CRON: Cleaning results: " + results);
    }

    @Transactional
    public Map<String, Object> doClean() {
        Instant now = Instant.now();

        List<String> orgReminders = sendOrgContactReminders(now);
        List<String> orgsDeleted = deleteOrgsWithNoConnections(now);
        Map<String, List<String>> userResults = cleanInactiveUsers(now);

        return Map.of(
                "orgReminders", orgReminders,
                "orgsDeleted", orgsDeleted,
                "usersWarned", userResults.get("usersWarned"),
                "usersDeleted", userResults.get("usersDeleted")
        );
    }

    private List<String> sendOrgContactReminders(Instant now) {
        Instant reminderCutoff = now.minus(orgContactReminderDays, ChronoUnit.DAYS);
        List<Organization> orgs = organizationRepository
                .findByManageIdentifierIsNotNullAndManageIdentifierIsNot("");

        LinkedHashSet<String> reminded = new LinkedHashSet<>();
        for (Organization org : orgs) {
            if (org.getLastContactReminderAt() != null && org.getLastContactReminderAt().isAfter(reminderCutoff)) {
                continue; // reminder sent recently enough
            }
            try {
                Map<String, Object> provider = manage.providerByManageIdentifier(
                        EntityType.saml20_idp, org.getManageIdentifier());
                if (provider == null || provider.isEmpty()) {
                    continue;
                }
                manage.sanitizeProvider(provider);
                org.mergeMetaData(provider, true);

                @SuppressWarnings("unchecked")
                List<Contact> contacts = (List<Contact>) org.getMetaData().get("contactPersons");
                if (contacts == null) {
                    continue;
                }
                boolean sent = false;
                for (Contact contact : contacts) {
                    if ("administrative".equalsIgnoreCase(contact.getType())) {
                        mailBox.sendOrgContactReminder(org, contact, "en");
                        sent = true;
                    }
                }
                if (sent) {
                    reminded.add(org.getName());
                    org.setLastContactReminderAt(now);
                    organizationRepository.save(org);
                }
            } catch (Exception e) {
                LOG.warn("Failed to send contact reminder for org " + org.getId() + ": " + e.getMessage());
            }
        }
        return new ArrayList<>(reminded);
    }

    private List<String> deleteOrgsWithNoConnections(Instant now) {
        Instant deleteCutoff = now.minus(orgDeleteAfterDays, ChronoUnit.DAYS);
        List<Organization> orgs = organizationRepository
                .findByManageIdentifierIsNullAndCreatedAtBefore(deleteCutoff);

        List<String> deleted = new ArrayList<>();
        for (Organization org : orgs) {
            boolean hasConnections = org.getApplications().stream()
                    .anyMatch(app -> !app.getConnections().isEmpty());
            if (!hasConnections) {
                LOG.info("CRON: Deleting org with no connections: " + org.getId() + " (" + org.getName() + ")");
                organizationRepository.deleteOrganizationById(org.getId());
                deleted.add(org.getName());
            }
        }
        return deleted;
    }

    private Map<String, List<String>> cleanInactiveUsers(Instant now) {
        Instant warnCutoff = now.minus(userInactivityWarnDays, ChronoUnit.DAYS);
        Instant deleteCutoff = now.minus(userInactivityDeleteDays, ChronoUnit.DAYS);

        List<User> inactiveUsers = userRepository.findInactiveUsersWithMemberships(warnCutoff);

        List<String> warned = new ArrayList<>();
        List<String> deleted = new ArrayList<>();
        for (User user : inactiveUsers) {
            Instant activity = user.getLastActivity();
            boolean shouldDelete = activity == null || activity.isBefore(deleteCutoff);
            if (shouldDelete) {
                LOG.info("CRON: Deleting inactive user: " + user.getId() + " (" + user.getEmail() + ")");
                userRepository.deleteById(user.getId());
                deleted.add(user.getEmail());
            } else {
                // between warn cutoff and delete cutoff — send warning (once only)
                if (user.getInactivityWarningSentAt() != null) {
                    continue; // warning already sent; don't spam
                }
                Instant deletionDate = activity.plus(userInactivityDeleteDays, ChronoUnit.DAYS);
                mailBox.sendUserInactivityWarning(user, deletionDate);
                user.setInactivityWarningSentAt(now);
                userRepository.save(user);
                warned.add(user.getEmail());
            }
        }
        return Map.of("usersWarned", warned, "usersDeleted", deleted);
    }
}