Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4317af3
implement last activity at
sachmii Jan 14, 2026
eccea16
add missing comment
sachmii Jan 14, 2026
3b0c16d
Merge branch 'main' into feat/1696-delete-user-data-after-inactivity
sachmii Jan 14, 2026
bb36eec
chore: update OpenAPI spec and generated client
github-actions[bot] Jan 14, 2026
20acce7
use localdatetime instead of instant
sachmii Jan 14, 2026
ba0a9de
Merge branch 'feat/1696-delete-user-data-after-inactivity' of https:/…
sachmii Jan 14, 2026
fb0e379
feat: 1. user retention properties in yaml file and java class 2. use…
sachmii Jan 15, 2026
0693cb3
feat: implemented applicant data deletion and necessary repository me…
sachmii Jan 15, 2026
2310815
merge
sachmii Jan 19, 2026
2238585
user retention i guess implemented so far
sachmii Jan 19, 2026
0044042
logic should be working
sachmii Jan 19, 2026
5b66be2
pls work
sachmii Jan 19, 2026
cfa1b4c
just oncew insert
sachmii Jan 19, 2026
b49e111
fix
sachmii Jan 19, 2026
d3f0860
fix codacy
sachmii Jan 19, 2026
f4d43c7
fix
sachmii Jan 19, 2026
8a9800c
nerv nicht
sachmii Jan 19, 2026
666f93a
fix server test
sachmii Jan 20, 2026
23acd57
fix test
sachmii Jan 20, 2026
7722a6b
fix docs
sachmii Jan 20, 2026
9151309
update docs
sachmii Jan 20, 2026
c6a2b89
tests
sachmii Jan 20, 2026
a4e0377
fix test security config
sachmii Jan 20, 2026
8ce1726
refactor test
sachmii Jan 20, 2026
e7bdcbc
fix data getter
sachmii Jan 22, 2026
0222b01
Merge branch 'main' of https://github.com/ls1intum/tum-apply into fea…
sachmii Jan 22, 2026
956a5cd
fix batch delete
sachmii Jan 22, 2026
c727f77
Merge branch 'feat/delete-user-data-after-inactivity' of https://gith…
sachmii Jan 22, 2026
ea3b8a9
fix batch delete
sachmii Jan 22, 2026
1a406a6
Merge branch 'feat/delete-user-data-after-inactivity' of https://gith…
sachmii Jan 22, 2026
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,42 @@
package de.tum.cit.aet.core.config;

import java.util.UUID;
import lombok.Data;
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.
*/
@Data
@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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
import de.tum.cit.aet.core.constants.DocumentType;
import de.tum.cit.aet.core.domain.DocumentDictionary;
import de.tum.cit.aet.usermanagement.domain.Applicant;
import java.util.List;
import java.util.Set;
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;

@Repository
Expand All @@ -20,4 +24,10 @@ public interface DocumentDictionaryRepository extends TumApplyJpaRepository<Docu
Set<DocumentDictionary> findAllByApplicant(Applicant applicant);

Set<DocumentDictionary> findAllByApplicationApplicationId(UUID applicationId);

void deleteByApplication(Application application);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM DocumentDictionary dd WHERE dd.application.applicationId IN :applicationIds")
void deleteByApplicationIdIn(@Param("applicationIds") List<UUID> applicationIds);
}
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