Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/main/java/de/tum/cit/aet/TumApplyApp.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import de.tum.cit.aet.core.config.ApplicationProperties;
import de.tum.cit.aet.core.config.CRLFLogConverter;
import de.tum.cit.aet.core.config.UserRetentionProperties;
import jakarta.annotation.PostConstruct;
import java.net.InetAddress;
import java.net.UnknownHostException;
Expand All @@ -20,7 +21,7 @@
import tech.jhipster.config.JHipsterConstants;

@SpringBootApplication
@EnableConfigurationProperties({ LiquibaseProperties.class, ApplicationProperties.class })
@EnableConfigurationProperties({ LiquibaseProperties.class, ApplicationProperties.class, UserRetentionProperties.class })
public class TumApplyApp {

private static final Logger LOG = LoggerFactory.getLogger(TumApplyApp.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package de.tum.cit.aet.core.config;

import java.util.UUID;
import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* Configuration properties for user retention and scheduled cleanup behavior.
* <p>
* Includes settings for inactivity thresholds, applicant data deletion timing,
* job execution parameters (cron, batch size, max runtime), and placeholders for
* representing deleted users (ID, email, names, language). Supports enabling/disabling
* the feature and running in dry-run mode.
*/
@ConfigurationProperties(prefix = "user.retention", ignoreUnknownFields = false)
public class UserRetentionProperties {

private Integer inactiveDaysBeforeDeletion;

private Integer daysBeforeApplicantDataDeletion;

private Boolean enabled;

private Boolean dryRun;

private Integer batchSize;

private Integer maxRuntimeMinutes;

private String cron;

private UUID deletedUserId;

private String deletedUserEmail;

private String deletedUserFirstName;

private String deletedUserLastName;

private String deletedUserLanguage;

public Integer getBatchSize() {
return batchSize;
}

public void setBatchSize(Integer batchSize) {
this.batchSize = batchSize;
}

public Integer getDaysBeforeApplicantDataDeletion() {
return daysBeforeApplicantDataDeletion;
}

public void setDaysBeforeApplicantDataDeletion(Integer daysBeforeApplicantDataDeletion) {
this.daysBeforeApplicantDataDeletion = daysBeforeApplicantDataDeletion;
}

public Boolean getDryRun() {
return dryRun;
}

public void setDryRun(Boolean dryRun) {
this.dryRun = dryRun;
}

public Boolean getEnabled() {
return enabled;
}

public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}

public Integer getInactiveDaysBeforeDeletion() {
return inactiveDaysBeforeDeletion;
}

public void setInactiveDaysBeforeDeletion(Integer inactiveDaysBeforeDeletion) {
this.inactiveDaysBeforeDeletion = inactiveDaysBeforeDeletion;
}

public Integer getMaxRuntimeMinutes() {
return maxRuntimeMinutes;
}

public void setMaxRuntimeMinutes(Integer maxRuntimeMinutes) {
this.maxRuntimeMinutes = maxRuntimeMinutes;
}

public String getCron() {
return cron;
}

public void setCron(String cron) {
this.cron = cron;
}

public UUID getDeletedUserId() {
return deletedUserId;
}

public void setDeletedUserId(UUID deletedUserId) {
this.deletedUserId = deletedUserId;
}

public String getDeletedUserEmail() {
return deletedUserEmail;
}

public void setDeletedUserEmail(String deletedUserEmail) {
this.deletedUserEmail = deletedUserEmail;
}

public String getDeletedUserFirstName() {
return deletedUserFirstName;
}

public void setDeletedUserFirstName(String deletedUserFirstName) {
this.deletedUserFirstName = deletedUserFirstName;
}

public String getDeletedUserLastName() {
return deletedUserLastName;
}

public void setDeletedUserLastName(String deletedUserLastName) {
this.deletedUserLastName = deletedUserLastName;
}

public String getDeletedUserLanguage() {
return deletedUserLanguage;
}

public void setDeletedUserLanguage(String deletedUserLanguage) {
this.deletedUserLanguage = deletedUserLanguage;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ public interface DocumentDictionaryRepository extends TumApplyJpaRepository<Docu
Set<DocumentDictionary> findAllByApplicant(Applicant applicant);

Set<DocumentDictionary> findAllByApplicationApplicationId(UUID applicationId);

void deleteByApplication(Application application);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package de.tum.cit.aet.core.repository;

import de.tum.cit.aet.core.domain.Document;
import de.tum.cit.aet.usermanagement.domain.User;
import java.util.Optional;
import java.util.UUID;
import org.springframework.stereotype.Repository;

@Repository
public interface DocumentRepository extends TumApplyJpaRepository<Document, UUID> {
Optional<Document> findBySha256Id(String sha256Id);

void deleteByUploadedBy(User uploadedBy);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import de.tum.cit.aet.core.domain.DepartmentImage;
import de.tum.cit.aet.core.domain.Image;
import de.tum.cit.aet.core.domain.ResearchGroupImage;
import de.tum.cit.aet.usermanagement.domain.User;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
Expand Down Expand Up @@ -86,4 +88,24 @@ default List<DepartmentImage> findDefaultJobBannersBySchool(UUID schoolId) {
*/
@Query("SELECT di FROM DepartmentImage di WHERE di.department IS NULL")
List<DepartmentImage> findOrphanedDepartmentImages();

/**
* Deletes the profile image associated with the given user.
*
* @param userId the ID of the user whose profile image should be deleted
*/
@Modifying
@Query("DELETE FROM Image i WHERE i.uploadedBy.userId = :userId AND TYPE(i) = ProfileImage")
void deleteProfileImageByUser(@Param("userId") UUID userId);

/**
* Updates all {@link Image} records uploaded by the given {@code user} to associate them with the
* provided {@code deletedUser} instead of the original user.
*
* @param user the user whose uploaded images should be dissociated
* @param deletedUser the user to set as the uploader for those images
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Image i SET i.uploadedBy = :deletedUser WHERE i.uploadedBy = :user")
void dissociateImagesFromUser(@Param("user") User user, @Param("deletedUser") User deletedUser);
}
128 changes: 128 additions & 0 deletions src/main/java/de/tum/cit/aet/core/retention/UserRetentionJob.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package de.tum.cit.aet.core.retention;

import de.tum.cit.aet.core.config.UserRetentionProperties;
import de.tum.cit.aet.usermanagement.repository.UserRepository;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@Slf4j
public class UserRetentionJob {

private final UserRetentionProperties properties;
private final UserRepository userRepository;
private final UserRetentionService userRetentionService;

/**
* Scheduled job that performs user retention cleanup based on configured settings.
* <p>
* If retention is disabled or no valid run configuration is available, the method exits early.
* Otherwise, it builds a run configuration, processes candidate records within the maximum
* runtime window, and logs a summary including dry-run status, cutoff timestamp, number of
* candidates seen, actual runtime, and configured maximum runtime.
*/
// Runs daily at 03:17 UTC (override with user.retention.cron)
@Scheduled(cron = "${user.retention.cron:0 17 3 * * *}", zone = "UTC")
public void deleteUserData() {
if (!Boolean.TRUE.equals(properties.getEnabled())) {
return;
}

RetentionRunConfig config = buildRunConfig();
if (config == null) {
return;
}

Instant start = Instant.now();
Instant deadline = start.plus(Duration.ofMinutes(config.maxRuntimeMinutes()));

RetentionRunResult result = processCandidates(config, deadline);

Duration runtime = Duration.between(start, Instant.now());
log.info(
"User retention run finished: enabled=true dryRun={} cutoff={} candidatesSeen={} runtimeMs={} maxRuntimeMinutes={}",
config.dryRun(),
config.cutoff(),
result.totalCandidatesSeen(),
runtime.toMillis(),
config.maxRuntimeMinutes()
);
}

private RetentionRunConfig buildRunConfig() {
Integer inactiveDays = properties.getInactiveDaysBeforeDeletion();
Integer batchSize = properties.getBatchSize();
Integer maxRuntimeMinutes = properties.getMaxRuntimeMinutes();

if (inactiveDays == null || inactiveDays <= 0) {
log.warn("User retention enabled, but inactiveDaysBeforeDeletion is not configured (value={})", inactiveDays);
return null;
}
if (batchSize == null || batchSize <= 0) {
log.warn("User retention enabled, but batchSize is not configured (value={})", batchSize);
return null;
}
if (maxRuntimeMinutes == null || maxRuntimeMinutes <= 0) {
log.warn("User retention enabled, but maxRuntimeMinutes is not configured (value={})", maxRuntimeMinutes);
return null;
}

LocalDateTime nowUtc = LocalDateTime.now(ZoneOffset.UTC);
LocalDateTime cutoff = nowUtc.minusDays(inactiveDays);
boolean dryRun = Boolean.TRUE.equals(properties.getDryRun());

return new RetentionRunConfig(inactiveDays, batchSize, maxRuntimeMinutes, cutoff, dryRun);
}

private RetentionRunResult processCandidates(RetentionRunConfig config, Instant deadline) {
long totalCandidatesSeen = 0;
int pageNumber = 0;

while (Instant.now().isBefore(deadline)) {
PageRequest pageRequest = config.dryRun()
? PageRequest.of(pageNumber, config.batchSize())
: PageRequest.of(0, config.batchSize());

Page<UUID> userIds = userRepository.findInactiveNonAdminUserIdsForRetention(config.cutoff(), pageRequest);
List<UUID> ids = userIds.getContent();

if (ids.isEmpty()) {
break;
}

totalCandidatesSeen += ids.size();

userRetentionService.processUserIdsList(ids, config.cutoff(), config.dryRun());

if (config.dryRun()) {
if (!userIds.hasNext()) {
break;
}
pageNumber++;
}
}

return new RetentionRunResult(totalCandidatesSeen);
}

private record RetentionRunConfig(
Integer inactiveDays,
Integer batchSize,
Integer maxRuntimeMinutes,
LocalDateTime cutoff,
boolean dryRun
) {}

private record RetentionRunResult(long totalCandidatesSeen) {}
}
Loading
Loading