From 35177276a196ae18bdb158595611d057ec3d210e Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 11:05:52 +0100
Subject: [PATCH 01/39] Rewrite privacy statement for GDPR compliance
Completely rewrites the privacy statement to accurately reflect the
application's data processing activities. Adds sections for user account
data, uploaded documents, thesis data, email notifications, calendar
feeds, authentication, server logging, data retention periods, data
recipients, and full GDPR rights. Also adds a data retention policy
document with rationale for retention periods.
Co-Authored-By: Claude Opus 4.6
---
client/public/privacy.html | 257 ++++++++++++++++++++++++++++---------
docs/DATA_RETENTION.md | 53 ++++++++
2 files changed, 251 insertions(+), 59 deletions(-)
create mode 100644 docs/DATA_RETENTION.md
diff --git a/client/public/privacy.html b/client/public/privacy.html
index f2324c6ee..5a9e3dd8e 100644
--- a/client/public/privacy.html
+++ b/client/public/privacy.html
@@ -1,68 +1,207 @@
The Research Group for Applied Education Technologies (referred to as AET in the following paragraphs) from the
- Technical University of Munich takes the protection of private data seriously. We process the automatically collected
- personal data obtained when you visit our website, in compliance with the applicable data protection regulations, in
- particular the Bavarian Data Protection (BayDSG), the Telemedia Act (TMG) and the General Data Protection Regulation
- (GDPR). Below, we inform you about the type, scope and purpose of the collection and use of personal data.
-
Logging
-
The web servers of the AET are operated by the AET itself, based in Boltzmannstr. 3, 85748 Garching b. Munich. Every
- time our website is accessed, the web server temporarily processes the following information in log files:
+ Technical University of Munich takes the protection of private data seriously. We process personal data collected
+ when you visit and use our application, in compliance with the applicable data protection regulations, in particular
+ the Bavarian Data Protection Act (BayDSG), the Telecommunications Digital Services Data Protection Act (TDDDG) and
+ the General Data Protection Regulation (GDPR). Below, we inform you about the type, scope and purpose of the
+ collection and use of personal data.
+
+
Controller
+
The controller responsible for data processing within the meaning of the GDPR is:
+
+ Technical University of Munich
+ Research Group for Applied Education Technologies (AET)
+ Boltzmannstr. 3
+ 85748 Garching b. Munich
+ Email: ls1.admin@in.tum.de
+
+
+
Data Protection Officer
+
The Data Protection Officer of the Technical University of Munich can be reached at:
When you log in to this application via your university account (Single Sign-On via Keycloak), the following personal
+ data is automatically retrieved from your university identity provider and stored:
+
+
First name and last name
+
Email address
+
University ID (username)
+
Matriculation number (if available)
+
University role assignments (student, supervisor, advisor, administrator)
+
+
In addition, you may voluntarily provide the following information in your user profile:
+
+
Gender
+
Nationality
+
Study degree and study program
+
Enrollment semester
+
Interests, projects, and special skills (free text)
+
Additional configurable fields as required by your research group
+
+
Legal basis: Processing of authentication data is necessary for the performance of the university's
+ public task of organizing and assessing theses (Art. 6(1)(e) GDPR in conjunction with Art. 4(1) BayDSG). Processing
+ of voluntarily provided profile data is based on your consent (Art. 6(1)(a) GDPR).
+
+
Uploaded documents
+
As part of the thesis application process, you may upload the following documents:
+
+
Curriculum vitae (CV)
+
Examination report
+
Degree report (for Master's students)
+
Profile picture (avatar)
+
+
During the thesis process, additional documents may be uploaded, such as thesis proposals, final thesis submissions,
+ and supporting files. These documents are stored on the application server and are accessible only to authorized
+ personnel (supervisors, advisors, and administrators of the relevant research group).
+
Legal basis: Consent (Art. 6(1)(a) GDPR) and performance of the university's public task
+ (Art. 6(1)(e) GDPR).
+
+
Thesis and application data
+
When you apply for or work on a thesis, the following data is processed:
Assessment and grading data (feedback, grade suggestions, final grade)
+
Comments and communication within the thesis process
+
Presentation details (date, time, location, stream link)
+
Interview scheduling data (time slots, location, stream link)
+
+
Legal basis: Performance of the university's public task of organizing and assessing theses
+ (Art. 6(1)(e) GDPR).
+
+
Email notifications
+
The application sends automated email notifications related to the thesis process, including:
+
+
Application status updates (submission confirmation, acceptance, rejection)
+
Interview invitations and scheduling confirmations
+
Thesis proposal and submission notifications
+
Presentation scheduling notifications
+
Comment notifications
+
Final grade notifications
+
+
These emails may contain personal data such as your name, thesis title, and relevant process details. The research
+ group head receives a copy (BCC) of application acceptance, rejection, and thesis lifecycle emails. Additionally,
+ research groups may configure an additional notification email address that receives a copy of every new application
+ (including attached documents such as CV and examination reports). You can configure your notification preferences in
+ the application settings.
+
Legal basis: Performance of the university's public task (Art. 6(1)(e) GDPR) and legitimate interest
+ in ensuring efficient communication (Art. 6(1)(f) GDPR).
+
+
Calendar subscription feeds
+
The application provides calendar subscription feeds (ICS format) that authorized users can subscribe to in their
+ calendar applications (e.g. Outlook, Google Calendar, Apple Calendar). These feeds contain:
+
+
Presentation calendar: Thesis titles, presentation dates, locations or stream links, and names
+ of participants for public thesis presentations.
+
Interview calendar: Interview time slots, locations or stream links, and topic titles for
+ individual users.
+
+
Legal basis: Legitimate interest in efficient scheduling and organization (Art. 6(1)(f) GDPR).
+
+
Authentication and session data
+
Authentication is handled through Keycloak (Single Sign-On) using your university credentials. The application does
+ not store your password. Authentication tokens (JWT) are stored in your browser's local storage for the duration of
+ your session. The following data is stored in your browser:
+
+
Authentication tokens (in localStorage, cleared on logout)
+
Privacy consent status (in localStorage)
+
User interface preferences (in localStorage)
+
+
Legal basis: These are technically necessary for the operation of the application (Art. 6(1)(e) GDPR
+ and Section 25(2) TDDDG).
+
+
Server logging
+
The web servers are operated by the AET. Every time the application is accessed, the web server temporarily processes
+ the following information in log files:
IP address of the requesting computer
Date and time of access
-
Name, URL and transferred data volume of the accessed file
-
Access status (requested file transferred, not found etc.)
+
Name, URL, and transferred data volume of the accessed resource
+
Access status (requested resource transferred, not found, etc.)
Identification data of the browser and operating system used (if transmitted by the requesting web browser)
Website from which access was made (if transmitted by the requesting web browser)
-
The processing of the data in this log file takes place as follows:
+
The log entries can be continuously and automatically evaluated in order to detect attacks on the web servers and
+ react accordingly. In individual cases, i.e. in the event of reported malfunctions, errors and security incidents,
+ a manual analysis may be carried out. Log files are automatically deleted after 90 days.
+
Legal basis: Legitimate interest in ensuring the security and stability of the application
+ (Art. 6(1)(f) GDPR).
+
+
Data retention
+
Personal data is stored for the duration necessary to fulfill the purposes described above:
-
The log entries are continuously updated automatically evaluated in order to be able to detect attacks on the web
- server and react accordingly.
-
-
In individual cases, i.e. in the case of reported disruptions, errors and security incidents, a manual analysis is
- carried out.
-
-
The IP addresses contained in the log entries are not merged with other databases by AET, so that no conclusions
- can be drawn about individual persons.
-
+
User account data: Accounts that have been inactive for more than one year are automatically
+ disabled. Disabled accounts and their associated profile data are deleted after the applicable retention period for
+ any linked thesis or application data has expired. If you log in again before deletion, your account is
+ reactivated.
+
Thesis data and accepted applications: Thesis data, including assessment and grading records and
+ the associated application, is retained for 5 years after the end of the calendar year in which the final thesis
+ grade was issued, in accordance with university examination regulations.
+
Rejected application data: Applications that are not accepted are retained for 1 year after
+ rejection to allow for inquiries and reapplications, after which they are deleted.
+
Uploaded documents: Retained for the same period as the associated thesis or application data.
+
Server log files: Automatically deleted after 90 days.
-
Use and transfer of personal data
-
Our website can be used without providing personal data. All services that might require any form of personal data
- (e.g. registration for events, contact forms) are offered on external sites, linked here. The use of contact data
- published as part of the imprint obligation by third parties to send unsolicited advertising and information material
- is hereby prohibited. The operators of the pages reserve the right to take legal action in the event of the
- unsolicited sending of advertising information, such as spam mails.
-
Revocation of your consent to data processing
-
Some data processing operations require your express consent possible. You can revoke your consent that you have
- already given at any time. A message by e-mail is sufficient for the revocation. The lawfulness of the data processing
- that took place up until the revocation remains unaffected by the revocation.
-
Right to file a complaint with the
- responsible supervisory authority
-
You have the right to lodge a complaint with the responsible supervisory authority in the event of a breach of data
- protection law. The responsible supervisory authority with regard to data protection issues is the Federal
- Commissioner for Data Protection and Freedom of Information of the state where our company is based. The following
- link provides a list of data protection authorities and their contact details: https://www.bfdi.bund.de/DE/Infothek/Anschriften_Links/anschriften_links-node.html.
-
-
Right to data portability
-
You have the right to request the data that we process automatically on the basis of your consent or in fulfillment
- of a contract to be handed over to you or a third party. The data is provided in a machine-readable format. If you
- request the direct transfer of the data to another person responsible, this will only be done if it is technically
- feasible.
-
Right to information, correction, blocking, and
- deletion
-
You have at any time within the framework of the applicable legal provisions the right to request information about
- your stored personal data, the origin of the data, its recipient and the purpose of the data processing, and if
- necessary, a right to correction, blocking or deletion of this data. You can contact us at any time via ls1.admin@in.tum.de regarding this and other questions on the subject of
- personal data.
-
SSL/TLS encryption
-
For security reasons and to protect the transmission of confidential content that you send to us send as a site
- operator, our website uses an SSL/TLS encryption. This means that data that you transmit via this website cannot be
- read by third parties. You can recognize an encrypted connection by the “https://” address line in your browser and by
- the lock symbol in the browser line.
-
E-mail security
-
If you e-mail us, your e-mail address will only be used for correspondence with you. Please note that data
- transmission on the Internet can have security gaps. Complete protection of data from access by third parties is not
- possible.
\ No newline at end of file
+
+
Data recipients
+
Your personal data may be accessible to the following recipients within the application:
+
+
Supervisors and advisors of the research group you are associated with can view your profile,
+ application data, uploaded documents, and thesis information.
+
Administrators can view and manage all data in the application.
+
Other students cannot view your personal data unless explicitly shared through the thesis process
+ (e.g. public thesis presentations).
+
+
Data is not transferred to recipients outside the university, except:
+
+
Email recipients — presentation invitations may be sent to external email addresses provided by supervisors.
+
+
+
Your rights
+
Under the GDPR, you have the following rights regarding your personal data:
+
+
Right of access (Art. 15 GDPR): You have the right to request information about the personal data
+ we process about you.
+
Right to rectification (Art. 16 GDPR): You have the right to request the correction of inaccurate
+ personal data. Some data (name, email, matriculation number) is synchronized from your university account and must
+ be corrected there.
+
Right to erasure (Art. 17 GDPR): You have the right to request the deletion of your personal
+ data. Voluntarily provided profile data and rejected application data will be deleted promptly upon request.
+ Thesis-related data (including accepted applications, assessments, and grades) is subject to mandatory retention
+ under university examination regulations and cannot be deleted until the retention period has expired. We will
+ inform you of any applicable retention obligations when processing your request.
+
Right to restriction of processing (Art. 18 GDPR): You have the right to request the restriction
+ of processing of your personal data under certain conditions.
+
Right to data portability (Art. 20 GDPR): You have the right to receive the personal data you
+ have provided to us in a structured, commonly used, and machine-readable format.
+
Right to object (Art. 21 GDPR): You have the right to object to the processing of your personal
+ data based on Art. 6(1)(e) or (f) GDPR at any time, for reasons arising from your particular situation.
+
Right to withdraw consent (Art. 7(3) GDPR): You can revoke any consent you have given at any
+ time. An informal message by email is sufficient. The lawfulness of the data processing carried out on the basis
+ of the consent until the revocation remains unaffected.
You have the right to lodge a complaint with a supervisory authority if you believe that the processing of your
+ personal data violates data protection law. The competent supervisory authority for the Technical University of Munich
+ is the Bayerische Landesbeauftragte für den Datenschutz (BayLfD). You can also contact the Data Protection Officer of
+ the Technical University of Munich (see above).
+
+
SSL/TLS encryption
+
For security reasons and to protect the transmission of confidential content, this application uses SSL/TLS
+ encryption. This means that data you transmit via this application cannot be read by third parties. You can recognize
+ an encrypted connection by the "https://" address line in your browser and by the lock symbol in the browser line.
+
+
Email security
+
If you email us, your email address will only be used for correspondence with you. Please note that data transmission
+ on the Internet can have security gaps. Complete protection of data from access by third parties is not possible.
+
+
Changes to this privacy statement
+
We reserve the right to update this privacy statement to reflect changes in our data processing practices or legal
+ requirements. The current version is always available at the privacy page of this application.
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
new file mode 100644
index 000000000..99f2ab69c
--- /dev/null
+++ b/docs/DATA_RETENTION.md
@@ -0,0 +1,53 @@
+# Data Retention Policy
+
+This document describes the data retention periods used in the Thesis Management application and the rationale behind them. It serves as internal documentation for GDPR accountability (Art. 5(2) GDPR).
+
+## Retention Periods
+
+| Data Category | Retention Period | Rationale |
+|--------------------------------|---------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|
+| **Thesis data (incl. accepted application)** | 5 years after end of the calendar year of final grading | Required by Bavarian examination regulations. The accepted application is retained for the same period because it documents the basis for thesis topic selection and may be needed to defend the suitability of the topic in case of disputes. |
+| **Rejected application data** | 1 year after rejection | See rationale below. |
+| **Uploaded documents** | Same as associated thesis or application data | Documents (CV, examination reports, degree reports, thesis files) follow the retention period of the record they belong to. |
+| **Server log files** | 90 days | Sufficient for security monitoring and incident investigation. Standard practice for web server logs. |
+| **User account data** | Disabled after 1 year of inactivity, deleted after linked data retention periods expire | Accounts inactive for 1+ year are automatically disabled. Profile data is deleted once no linked thesis/application data requires retention. Logging in reactivates a disabled account. |
+
+## Rationale: 1-Year Retention for Rejected Applications
+
+Rejected applications are not examination records and therefore not subject to the 5-year retention required by examination regulations. Under GDPR's data minimization principle (Art. 5(1)(e)), they should not be kept longer than necessary.
+
+A 1-year retention period was chosen for the following reasons:
+
+1. **Reapplication cycles**: Students often reapply in the following semester. Retaining previous applications allows advisors to understand context and avoid redundant reviews.
+2. **Inquiries and complaints**: Students may inquire about or contest a rejection. A 1-year window covers typical academic complaint timelines.
+3. **Semester alignment**: One year covers at least two full semester cycles (winter and summer), which is the natural rhythm of thesis applications.
+4. **Proportionality**: One year is long enough to serve legitimate operational needs while being short enough to comply with GDPR data minimization. Longer periods (e.g. 2+ years) would be difficult to justify for data where no examination relationship was established.
+
+## Handling Deletion Requests (Art. 17 GDPR)
+
+When a user requests deletion of their data, the response depends on the legal basis for processing:
+
+| Data Category | Deletion on Request? | Reason |
+|---|---|---|
+| **Voluntarily provided profile data** (gender, nationality, interests, skills, CV, examination report) | **Yes** — delete promptly | Based on consent (Art. 6(1)(a)). User can withdraw consent at any time (Art. 7(3)). |
+| **Rejected application data** | **Yes** — delete promptly | Based on legitimate interest (Art. 6(1)(f)). No overriding grounds to refuse once the user objects (Art. 17(1)(c)). The 1-year period is the maximum retention, not a mandatory minimum. |
+| **Thesis data, grades, assessments, accepted applications** | **No** — retain until 5-year period expires | Based on public task (Art. 6(1)(e)) and required by examination regulations. Exempt from right to erasure under Art. 17(3)(b) (legal obligation) and Art. 17(3)(d) (archiving in public interest). Inform the user of the reason and the expected deletion date. |
+| **SSO-synced data** (name, email, university ID, matriculation number) | **Not meaningful** — re-synced on every login | This data is retrieved from Keycloak on each authentication. Deleting it would have no lasting effect. The practical approach is account deactivation, which prevents further logins and data syncing. |
+
+### Process for Handling Deletion Requests
+
+1. Identify which data categories the user's request covers.
+2. Delete all data where no legal retention obligation applies (profile data, rejected applications).
+3. For thesis-related data subject to mandatory retention, inform the user:
+ - Which data cannot be deleted and why (cite examination regulations).
+ - When the data will be deleted (end of the 5-year retention period).
+4. If the user has no active thesis and no retained examination data, offer full account deactivation.
+5. Document the request and the actions taken for accountability purposes.
+
+## Implementation Status
+
+- [ ] Automatic deletion of rejected applications after 1 year (not yet implemented)
+- [ ] Automatic deletion/archival of thesis data after 5-year retention period (not yet implemented)
+- [ ] Automatic disabling of inactive accounts after 1 year of inactivity (not yet implemented)
+- [ ] Deletion of disabled user accounts after linked data retention periods expire (not yet implemented)
+- [ ] Reactivation of disabled accounts on login (not yet implemented)
From 5a91ad3bbbca2085e6bdf222c9058f2340e8332a Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 11:06:22 +0100
Subject: [PATCH 02/39] Remove unused MAIL_BCC_RECIPIENTS configuration
The MAIL_BCC_RECIPIENTS environment variable was read but never used.
The actual BCC functionality uses the research group head directly.
Removes the dead config from MailConfig, application.yml, Docker
Compose, GitHub Actions workflow, and documentation.
Co-Authored-By: Claude Opus 4.6
---
.github/workflows/deploy_docker.yml | 4 +---
docker-compose.prod.yml | 1 -
docs/CONFIGURATION.md | 1 -
docs/MAILS.md | 16 ++++++++--------
.../tum/cit/aet/thesis/utility/MailConfig.java | 2 --
server/src/main/resources/application.yml | 1 -
server/src/test/resources/application.yml | 1 -
7 files changed, 9 insertions(+), 17 deletions(-)
diff --git a/.github/workflows/deploy_docker.yml b/.github/workflows/deploy_docker.yml
index 6c86340de..ec5b6d255 100644
--- a/.github/workflows/deploy_docker.yml
+++ b/.github/workflows/deploy_docker.yml
@@ -105,7 +105,6 @@ jobs:
SCIENTIFIC_WRITING_GUIDE: ${{ vars.SCIENTIFIC_WRITING_GUIDE }}
MAIL_SENDER: ${{ vars.MAIL_SENDER }}
MAIL_SIGNATURE: ${{ vars.MAIL_SIGNATURE }}
- MAIL_BCC_RECIPIENTS: ${{ vars.MAIL_BCC_RECIPIENTS }}
MAIL_WORKSPACE_URL: ${{ vars.MAIL_WORKSPACE_URL }}
KEYCLOAK_HOST: ${{ vars.KEYCLOAK_HOST }}
KEYCLOAK_REALM_NAME: ${{ vars.KEYCLOAK_REALM_NAME }}
@@ -125,7 +124,7 @@ jobs:
proxy_username: ${{ vars.DEPLOYMENT_GATEWAY_USER }}
proxy_key: ${{ secrets.DEPLOYMENT_GATEWAY_SSH_KEY }}
proxy_port: ${{ vars.DEPLOYMENT_GATEWAY_PORT }}
- envs: SERVER_TAG,CLIENT_TAG,SPRING_DATASOURCE_DATABASE,SPRING_DATASOURCE_USERNAME,SPRING_DATASOURCE_PASSWORD,APP_HOSTNAME,SERVER_HOST,CLIENT_HOST,APPLICATION_TITLE,CHAIR_NAME,CHAIR_URL,DEFAULT_SUPERVISOR_UUID,ALLOW_SUGGESTED_TOPICS,THESIS_TYPES,STUDY_PROGRAMS,STUDY_DEGREES,GENDERS,LANGUAGES,CUSTOM_DATA,THESIS_FILES,SCIENTIFIC_WRITING_GUIDE,MAIL_SENDER,MAIL_SIGNATURE,MAIL_BCC_RECIPIENTS,MAIL_WORKSPACE_URL,KEYCLOAK_HOST,KEYCLOAK_REALM_NAME,KEYCLOAK_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_SECRET,KEYCLOAK_SERVICE_STUDENT_GROUP_NAME,CALDAV_ENABLED,CALDAV_URL,CALDAV_USERNAME,CALDAV_PASSWORD
+ envs: SERVER_TAG,CLIENT_TAG,SPRING_DATASOURCE_DATABASE,SPRING_DATASOURCE_USERNAME,SPRING_DATASOURCE_PASSWORD,APP_HOSTNAME,SERVER_HOST,CLIENT_HOST,APPLICATION_TITLE,CHAIR_NAME,CHAIR_URL,DEFAULT_SUPERVISOR_UUID,ALLOW_SUGGESTED_TOPICS,THESIS_TYPES,STUDY_PROGRAMS,STUDY_DEGREES,GENDERS,LANGUAGES,CUSTOM_DATA,THESIS_FILES,SCIENTIFIC_WRITING_GUIDE,MAIL_SENDER,MAIL_SIGNATURE,MAIL_WORKSPACE_URL,KEYCLOAK_HOST,KEYCLOAK_REALM_NAME,KEYCLOAK_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_SECRET,KEYCLOAK_SERVICE_STUDENT_GROUP_NAME,CALDAV_ENABLED,CALDAV_URL,CALDAV_USERNAME,CALDAV_PASSWORD
script: |
rm -f .env.prod
cat > .env.prod << ENVEOF
@@ -150,7 +149,6 @@ jobs:
SCIENTIFIC_WRITING_GUIDE=${SCIENTIFIC_WRITING_GUIDE}
MAIL_SENDER=${MAIL_SENDER}
MAIL_SIGNATURE=${MAIL_SIGNATURE}
- MAIL_BCC_RECIPIENTS=${MAIL_BCC_RECIPIENTS}
MAIL_WORKSPACE_URL=${MAIL_WORKSPACE_URL}
KEYCLOAK_HOST=${KEYCLOAK_HOST}
KEYCLOAK_REALM_NAME=${KEYCLOAK_REALM_NAME}
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 54c71a585..cb8b1252c 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -68,7 +68,6 @@ services:
- MAIL_WORKSPACE_URL
- MAIL_SENDER
- MAIL_SIGNATURE
- - MAIL_BCC_RECIPIENTS
- CALDAV_ENABLED
- CALDAV_URL
- CALDAV_USERNAME
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 4e2f6b0c8..da7119fde 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -29,7 +29,6 @@ These are all environment variables that can be used to configure the applicatio
| MAIL_SENDER | server | test@ios.ase.cit.tum.de | Sender email address |
| MAIL_SIGNATURE | server | | Signature of the chair's supervisor / of the chair in general |
| MAIL_WORKSPACE_URL | server | https://slack.com | URL to the workspace where students can connect with advisors and supervisors |
-| MAIL_BCC_RECIPIENTS | server | | Default BCC recipients for important emails |
| UPLOAD_FOLDER | server | uploads | Folder where uploaded files will be stored |
| SCIENTIFIC_WRITING_GUIDE | server | | Link to a guide that explains scientific writing at the chair |
| APPLICATION_TITLE | client | Thesis Management | HTML title of the client |
diff --git a/docs/MAILS.md b/docs/MAILS.md
index 2fd962f23..0351ecb2c 100644
--- a/docs/MAILS.md
+++ b/docs/MAILS.md
@@ -9,16 +9,16 @@ If no research group specific template is found, the default template will be us
| Template Case | TO | CC | BCC | Description |
|------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------|-----------------------|-----------------------|--------------------------------------------------------------------------------|
-| APPLICATION_ACCEPTED | Application Student | Supervisor, Advisor | `MAIL_BCC_RECIPIENTS` | Application was accepted with different advisor and supervisor |
-| APPLICATION_ACCEPTED_NO_ADVISOR | Application Student | Supervisor, Advisor | `MAIL_BCC_RECIPIENTS` | Application was accepted with same advisor and supervisor |
+| APPLICATION_ACCEPTED | Application Student | Supervisor, Advisor | Research Group Head | Application was accepted with different advisor and supervisor |
+| APPLICATION_ACCEPTED_NO_ADVISOR | Application Student | Supervisor, Advisor | Research Group Head | Application was accepted with same advisor and supervisor |
| APPLICATION_CREATED_CHAIR | Chair Members | | | All supervisors and advisors get a summary about a new application |
| APPLICATION_CREATED_STUDENT | Application User | | | Confirmation email to the applying student when application was submitted |
-| APPLICATION_REJECTED | Application User | | `MAIL_BCC_RECIPIENTS` | Application was rejected |
-| APPLICATION_REJECTED_TOPIC_REQUIREMENTS | Application User | | `MAIL_BCC_RECIPIENTS` | Application was rejected because topic requirements were not met |
-| APPLICATION_REJECTED_STUDENT_REQUIREMENTS | Application User | | `MAIL_BCC_RECIPIENTS` | Application was rejected because student does not fulfil chair's requirements |
-| APPLICATION_REJECTED_TITLE_NOT_INTERESTING | Application User | | `MAIL_BCC_RECIPIENTS` | Application was rejected because the suggested thesis title is not interesting |
-| APPLICATION_REJECTED_TOPIC_FILLED | Application User | | `MAIL_BCC_RECIPIENTS` | Application was rejected because topic was closed |
-| APPLICATION_REJECTED_TOPIC_OUTDATED | Application User | | `MAIL_BCC_RECIPIENTS` | Application was rejected because topic is outdated |
+| APPLICATION_REJECTED | Application User | | Research Group Head | Application was rejected |
+| APPLICATION_REJECTED_TOPIC_REQUIREMENTS | Application User | | Research Group Head | Application was rejected because topic requirements were not met |
+| APPLICATION_REJECTED_STUDENT_REQUIREMENTS | Application User | | Research Group Head | Application was rejected because student does not fulfil chair's requirements |
+| APPLICATION_REJECTED_TITLE_NOT_INTERESTING | Application User | | Research Group Head | Application was rejected because the suggested thesis title is not interesting |
+| APPLICATION_REJECTED_TOPIC_FILLED | Application User | | Research Group Head | Application was rejected because topic was closed |
+| APPLICATION_REJECTED_TOPIC_OUTDATED | Application User | | Research Group Head | Application was rejected because topic is outdated |
| APPLICATION_REMINDER | Chair Members | | | Weekly email if there are more than 10 unreviewed applications |
| THESIS_ASSESSMENT_ADDED | Supervisors | | | Assessment was added to a submitted thesis |
| THESIS_CLOSED | Students | Supervisors, Advisors | | Thesis was closed before completion |
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/utility/MailConfig.java b/server/src/main/java/de/tum/cit/aet/thesis/utility/MailConfig.java
index 82ad2ced7..7bae7f2af 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/utility/MailConfig.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/utility/MailConfig.java
@@ -42,7 +42,6 @@ public class MailConfig {
*
* @param enabled whether email sending is enabled
* @param sender the sender email address
- * @param bccRecipientsList the BCC recipients list
* @param mailSignature the email signature
* @param workspaceUrl the workspace URL
* @param clientHost the client host URL
@@ -53,7 +52,6 @@ public class MailConfig {
public MailConfig(
@Value("${thesis-management.mail.enabled}") boolean enabled,
@Value("${thesis-management.mail.sender}") InternetAddress sender,
- @Value("${thesis-management.mail.bcc-recipients}") String bccRecipientsList,
@Value("${thesis-management.mail.signature}") String mailSignature,
@Value("${thesis-management.mail.workspace-url}") String workspaceUrl,
@Value("${thesis-management.client.host}") String clientHost,
diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml
index a9aed534e..fd575d801 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -93,7 +93,6 @@ thesis-management:
sender: ${MAIL_SENDER:test@ios.ase.cit.tum.de}
signature: ${MAIL_SIGNATURE:}
workspace-url: ${MAIL_WORKSPACE_URL:https://slack.com}
- bcc-recipients: ${MAIL_BCC_RECIPIENTS:}
storage:
upload-location: ${UPLOAD_FOLDER:uploads}
scientific-writing-guide: ${SCIENTIFIC_WRITING_GUIDE:}
diff --git a/server/src/test/resources/application.yml b/server/src/test/resources/application.yml
index 5e4927d25..73dd3ddb8 100644
--- a/server/src/test/resources/application.yml
+++ b/server/src/test/resources/application.yml
@@ -64,7 +64,6 @@ thesis-management:
sender: test@ios.ase.cit.tum.de
signature: ""
workspace-url: https://slack.com
- bcc-recipients: ""
storage:
upload-location: uploads
scientific-writing-guide: http://localhost:3000/writing-guide
From 5149a1a10acd50ea1dab98428dd2cf76ad85693a Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 11:06:36 +0100
Subject: [PATCH 03/39] Replace Gravatar integration with server-side avatar
import
Remove automatic Gravatar URL generation from User entity and client.
Add opt-in "Import from Gravatar" button that fetches the profile
picture server-side using SHA-256 email hashing, so the user's IP
is never exposed to the external service. Add one-time migration task
to download existing profile pictures for users without a custom avatar.
Co-Authored-By: Claude Opus 4.6
---
.../components/AvatarInput/AvatarInput.tsx | 62 ++++++++-
.../AuthenticationProvider.tsx | 1 +
.../AuthenticationContext/context.ts | 1 +
client/src/utils/user.ts | 4 +-
.../thesis/controller/UserInfoController.java | 87 ++++++++++++-
.../thesis/cron/ProfilePictureMigration.java | 119 ++++++++++++++++++
.../de/tum/cit/aet/thesis/entity/User.java | 22 +---
.../aet/thesis/repository/UserRepository.java | 3 +
.../cit/aet/thesis/service/UploadService.java | 30 +++++
9 files changed, 298 insertions(+), 31 deletions(-)
create mode 100644 server/src/main/java/de/tum/cit/aet/thesis/cron/ProfilePictureMigration.java
diff --git a/client/src/components/UserInformationForm/components/AvatarInput/AvatarInput.tsx b/client/src/components/UserInformationForm/components/AvatarInput/AvatarInput.tsx
index b46208c9d..d9c3d8ef4 100644
--- a/client/src/components/UserInformationForm/components/AvatarInput/AvatarInput.tsx
+++ b/client/src/components/UserInformationForm/components/AvatarInput/AvatarInput.tsx
@@ -1,9 +1,28 @@
import { getAvatar } from '../../../../utils/user'
import AvatarEditor from 'react-avatar-editor'
-import { useLoggedInUser } from '../../../../hooks/authentication'
-import { Avatar, Button, Center, Group, Input, Modal, Slider, Stack, Text } from '@mantine/core'
+import { useAuthenticationContext, useLoggedInUser } from '../../../../hooks/authentication'
+import {
+ Avatar,
+ Button,
+ Center,
+ Group,
+ Input,
+ Modal,
+ Slider,
+ Stack,
+ Text,
+ Tooltip,
+} from '@mantine/core'
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone'
import { useMemo, useRef, useState } from 'react'
+import { doRequest } from '../../../../requests/request'
+import { showSimpleError } from '../../../../utils/notification'
+import { IUser } from '../../../../requests/responses/user'
+
+const IMPORT_TOOLTIP =
+ 'Imports your profile picture from Gravatar (gravatar.com), a US-based service.' +
+ ' Your email hash is sent from the server, so your IP address is not exposed to the external service.' +
+ ' The image is only fetched once and stored locally.'
interface IAvatarInputProps {
value: File | undefined
@@ -16,6 +35,7 @@ const AvatarInput = (props: IAvatarInputProps) => {
const { value, onChange, label, required } = props
const editorRef = useRef(null)
+ const { updateUser } = useAuthenticationContext()
const user = useLoggedInUser()
const avatarUrl = useMemo(() => {
@@ -24,6 +44,7 @@ const AvatarInput = (props: IAvatarInputProps) => {
const [file, setFile] = useState()
const [scale, setScale] = useState(1)
+ const [importLoading, setImportLoading] = useState(false)
const onSave = async () => {
const canvas = editorRef.current?.getImageScaledToCanvas().toDataURL()
@@ -39,6 +60,29 @@ const AvatarInput = (props: IAvatarInputProps) => {
setScale(1)
}
+ const importProfilePicture = async () => {
+ setImportLoading(true)
+
+ try {
+ const response = await doRequest('/v2/user-info/import-profile-picture', {
+ method: 'POST',
+ requiresAuth: true,
+ })
+
+ if (!response.ok) {
+ throw new Error('No profile picture found for your email address.')
+ }
+
+ updateUser(response.data)
+ } catch (e: unknown) {
+ const message =
+ e instanceof Error ? e.message : 'No profile picture found for your email address.'
+ showSimpleError(message)
+ } finally {
+ setImportLoading(false)
+ }
+ }
+
return (
{
+ {user.email && (
+
+
+
+
+
+ )}
setFile(undefined)}>
{file && (
diff --git a/client/src/providers/AuthenticationContext/AuthenticationProvider.tsx b/client/src/providers/AuthenticationContext/AuthenticationProvider.tsx
index 19a25a603..002ded176 100644
--- a/client/src/providers/AuthenticationContext/AuthenticationProvider.tsx
+++ b/client/src/providers/AuthenticationContext/AuthenticationProvider.tsx
@@ -198,6 +198,7 @@ const AuthenticationProvider = (props: PropsWithChildren) => {
isAuthenticated: !!authenticationTokens?.access_token,
user: authenticationTokens?.access_token ? user : undefined,
groups: [],
+ updateUser: setUser,
updateInformation: async (data, avatar, examinationReport, cv, degreeReport) => {
const formData = new FormData()
diff --git a/client/src/providers/AuthenticationContext/context.ts b/client/src/providers/AuthenticationContext/context.ts
index 9459a92a2..3d224e54d 100644
--- a/client/src/providers/AuthenticationContext/context.ts
+++ b/client/src/providers/AuthenticationContext/context.ts
@@ -9,6 +9,7 @@ export interface IAuthenticationContext {
isAuthenticated: boolean
user: IUser | undefined
groups: string[]
+ updateUser: (user: IUser) => void
updateInformation: (
data: PartialNull,
avatar: File | undefined,
diff --git a/client/src/utils/user.ts b/client/src/utils/user.ts
index 06f0ae38e..9f7e71a78 100644
--- a/client/src/utils/user.ts
+++ b/client/src/utils/user.ts
@@ -2,7 +2,7 @@ import { GLOBAL_CONFIG } from '../config/global'
import { IMinimalUser } from '../requests/responses/user'
export function getAvatar(user: IMinimalUser) {
- return user.avatar && !user.avatar.startsWith('http')
+ return user.avatar
? `${GLOBAL_CONFIG.server_host}/api/v2/avatars/${user.userId}?filename=${user.avatar}`
- : user.avatar || undefined
+ : undefined
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/controller/UserInfoController.java b/server/src/main/java/de/tum/cit/aet/thesis/controller/UserInfoController.java
index 9c37ad67c..e4b06814f 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/controller/UserInfoController.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/controller/UserInfoController.java
@@ -7,7 +7,9 @@
import de.tum.cit.aet.thesis.dto.UserDto;
import de.tum.cit.aet.thesis.entity.NotificationSetting;
import de.tum.cit.aet.thesis.entity.User;
+import de.tum.cit.aet.thesis.repository.UserRepository;
import de.tum.cit.aet.thesis.service.AuthenticationService;
+import de.tum.cit.aet.thesis.service.UploadService;
import de.tum.cit.aet.thesis.utility.RequestValidator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@@ -15,6 +17,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -22,6 +25,13 @@
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.security.MessageDigest;
+import java.time.Duration;
import java.util.List;
/** REST controller for managing the authenticated user's profile and notification settings. */
@@ -29,16 +39,17 @@
@RestController
@RequestMapping("/v2/user-info")
public class UserInfoController {
+ private static final String AVATAR_LOOKUP_URL = "https://www.gravatar.com/avatar/";
+
private final AuthenticationService authenticationService;
+ private final UserRepository userRepository;
+ private final UploadService uploadService;
- /**
- * Injects the authentication service.
- *
- * @param authenticationService the authentication service
- */
@Autowired
- public UserInfoController(AuthenticationService authenticationService) {
+ public UserInfoController(AuthenticationService authenticationService, UserRepository userRepository, UploadService uploadService) {
this.authenticationService = authenticationService;
+ this.userRepository = userRepository;
+ this.uploadService = uploadService;
}
/**
@@ -140,4 +151,68 @@ public ResponseEntity> updateNotificationSettings(
settings.stream().map(NotificationSettingDto::fromNotificationSettingEntity).toList()
);
}
+
+ /**
+ * Imports the authenticated user's profile picture from an external avatar service.
+ * The request is made server-side so that the user's IP address is not exposed to the external service.
+ *
+ * @param jwt the JWT authentication token
+ * @return the updated user profile information
+ */
+ @PostMapping("/import-profile-picture")
+ public ResponseEntity importProfilePicture(JwtAuthenticationToken jwt) {
+ User user = this.authenticationService.getAuthenticatedUser(jwt);
+
+ String email = user.getEmail() != null ? user.getEmail().getAddress() : null;
+ if (email == null || email.isBlank()) {
+ return ResponseEntity.badRequest().build();
+ }
+
+ try {
+ String hash = sha256Hex(email.trim().toLowerCase());
+ String lookupUrl = AVATAR_LOOKUP_URL + hash + "?s=400&d=404";
+
+ HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(lookupUrl))
+ .timeout(Duration.ofSeconds(10))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+
+ if (response.statusCode() != 200) {
+ return ResponseEntity.notFound().build();
+ }
+
+ byte[] imageBytes = response.body().readAllBytes();
+ String storedFilename = uploadService.storeBytes(imageBytes, "png", 1024 * 1024);
+ user.setAvatar(storedFilename);
+
+ user = userRepository.save(user);
+
+ return ResponseEntity.ok(UserDto.fromUserEntity(user));
+ } catch (Exception e) {
+ log.warn("Failed to import profile picture for user {}", user.getId(), e);
+ return ResponseEntity.internalServerError().build();
+ }
+ }
+
+ private String sha256Hex(String input) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hashBytes = digest.digest(input.getBytes());
+ StringBuilder sb = new StringBuilder();
+ for (byte b : hashBytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ throw new RuntimeException("SHA-256 not available", e);
+ }
+ }
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/cron/ProfilePictureMigration.java b/server/src/main/java/de/tum/cit/aet/thesis/cron/ProfilePictureMigration.java
new file mode 100644
index 000000000..1dee7ce19
--- /dev/null
+++ b/server/src/main/java/de/tum/cit/aet/thesis/cron/ProfilePictureMigration.java
@@ -0,0 +1,119 @@
+package de.tum.cit.aet.thesis.cron;
+
+import de.tum.cit.aet.thesis.entity.User;
+import de.tum.cit.aet.thesis.repository.UserRepository;
+import de.tum.cit.aet.thesis.service.UploadService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.util.List;
+
+/**
+ * One-time migration task that attempts to fetch existing profile pictures for users
+ * who don't have a custom avatar and stores them locally. After running successfully,
+ * this task can be removed from the codebase.
+ *
+ *
Uses an external avatar service to look up images by email hash. Only users with
+ * an existing profile picture get their image downloaded.
+ */
+@Component
+public class ProfilePictureMigration {
+ private static final Logger log = LoggerFactory.getLogger(ProfilePictureMigration.class);
+
+ private static final String AVATAR_LOOKUP_URL = "https://www.gravatar.com/avatar/";
+
+ private final UserRepository userRepository;
+ private final UploadService uploadService;
+
+ public ProfilePictureMigration(UserRepository userRepository, UploadService uploadService) {
+ this.userRepository = userRepository;
+ this.uploadService = uploadService;
+ }
+
+ /**
+ * Runs 5 minutes after server start, once only. Finds all users without a custom avatar,
+ * checks if they have an existing profile picture, and if so downloads and stores it locally.
+ */
+ @Scheduled(initialDelay = 5 * 60 * 1000, fixedDelay = Long.MAX_VALUE)
+ public void migrateProfilePictures() {
+ List usersWithoutAvatar = userRepository.findAllByAvatarIsNullOrAvatarIsEmpty();
+
+ if (usersWithoutAvatar.isEmpty()) {
+ log.info("Profile picture migration: no users without custom avatar found, skipping");
+ return;
+ }
+
+ log.info("Profile picture migration: checking {} users without custom avatar", usersWithoutAvatar.size());
+
+ HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build();
+
+ int downloaded = 0;
+ int skipped = 0;
+
+ for (User user : usersWithoutAvatar) {
+ try {
+ String email = user.getEmail() != null ? user.getEmail().getAddress() : null;
+ if (email == null || email.isBlank()) {
+ skipped++;
+ continue;
+ }
+
+ String hash = sha256Hex(email.trim().toLowerCase());
+ String lookupUrl = AVATAR_LOOKUP_URL + hash + "?s=400&d=404";
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(lookupUrl))
+ .timeout(Duration.ofSeconds(10))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+
+ if (response.statusCode() == 200) {
+ byte[] imageBytes = response.body().readAllBytes();
+
+ String storedFilename = uploadService.storeBytes(imageBytes, "png", 1024 * 1024);
+ user.setAvatar(storedFilename);
+ userRepository.save(user);
+ downloaded++;
+
+ log.info("Profile picture migration: downloaded avatar for user {} ({})",
+ user.getUniversityId(), user.getId());
+ } else {
+ skipped++;
+ }
+ } catch (Exception e) {
+ log.warn("Profile picture migration: failed to process user {}", user.getId(), e);
+ skipped++;
+ }
+ }
+
+ log.info("Profile picture migration completed: {} images downloaded, {} skipped", downloaded, skipped);
+ }
+
+ private String sha256Hex(String input) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hashBytes = digest.digest(input.getBytes());
+ StringBuilder sb = new StringBuilder();
+ for (byte b : hashBytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ throw new RuntimeException("SHA-256 not available", e);
+ }
+ }
+}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java b/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java
index 6b977419b..609ec1dd1 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java
@@ -20,8 +20,6 @@
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
@@ -130,25 +128,7 @@ public String getAdjustedAvatar() {
return avatar;
}
- if (email == null) {
- return null;
- }
-
- try {
- MessageDigest md = MessageDigest.getInstance("MD5");
-
- byte[] hashInBytes = md.digest(email.trim().toLowerCase().getBytes());
-
- StringBuilder sb = new StringBuilder();
-
- for (byte b : hashInBytes) {
- sb.append(String.format("%02x", b));
- }
-
- return "https://www.gravatar.com/avatar/" + sb + "?s=400";
- } catch (NoSuchAlgorithmException e) {
- return null;
- }
+ return null;
}
public boolean hasNoGroup() {
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java b/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java
index 5ea9fb154..c2df7a58b 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java
@@ -51,6 +51,9 @@ List getRoleMembers(@Param("roles") Set roles,
@Query("SELECT u.id FROM User u WHERE u.matriculationNumber IS NULL")
List findIdsWithoutMatriculationNumber();
+ @Query("SELECT u FROM User u WHERE u.avatar IS NULL OR u.avatar = ''")
+ List findAllByAvatarIsNullOrAvatarIsEmpty();
+
@Query("""
SELECT DISTINCT u FROM User u
WHERE u.id IN (
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/UploadService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/UploadService.java
index 5170dee2e..7a95ff478 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/UploadService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/UploadService.java
@@ -121,6 +121,36 @@ public FileSystemResource load(String filename) {
}
}
+ /**
+ * Stores raw bytes as a file with the given extension, returning the content-hashed filename.
+ *
+ * @param bytes the file content
+ * @param extension the file extension (e.g. "png")
+ * @param maxSize the maximum allowed size in bytes
+ * @return the content-hashed filename
+ */
+ public String storeBytes(byte[] bytes, String extension, int maxSize) {
+ try {
+ if (bytes == null || bytes.length == 0) {
+ throw new UploadException("Failed to store empty file");
+ }
+
+ if (bytes.length > maxSize) {
+ throw new UploadException("File size exceeds the maximum allowed size");
+ }
+
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hashBytes = digest.digest(bytes);
+ String hash = HexFormat.of().formatHex(hashBytes);
+ String filename = hash + "." + extension;
+
+ Files.write(rootLocation.resolve(filename), bytes);
+ return filename;
+ } catch (IOException | NoSuchAlgorithmException e) {
+ throw new UploadException("Failed to store file", e);
+ }
+ }
+
private String computeFileHash(MultipartFile file) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream inputStream = file.getInputStream()) {
From 70e46f4999738cb09c07d7c423ebfe36c5127a74 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 11:38:15 +0100
Subject: [PATCH 04/39] Add interview assessment data to privacy statement and
document automatic application expiration
Mention interview assessment notes in the privacy statement. Add
automatic application expiration documentation to README (user-facing)
and DATA_RETENTION.md (internal rationale with GDPR Art. 22
clarification).
Co-Authored-By: Claude Opus 4.6
---
README.md | 6 ++++++
client/public/privacy.html | 2 +-
docs/DATA_RETENTION.md | 11 +++++++++++
3 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index b8c188db7..4294302ba 100644
--- a/README.md
+++ b/README.md
@@ -177,6 +177,12 @@ These flowcharts offer a quick reference for understanding how each role engages

+#### Automatic Application Expiration
+
+Applications that have not been reviewed within a configurable period are automatically rejected. Research group admins can configure the expiration delay in weeks (minimum 2 weeks) in the research group settings. When an application expires, the student receives the standard rejection email notification, so they can reapply or pursue other options.
+
+This mechanism ensures that students are not left waiting indefinitely for a response and enables the system to clean up application data after the retention period.
+
#### Thesis Writing Flowchart

diff --git a/client/public/privacy.html b/client/public/privacy.html
index 5a9e3dd8e..476b87daa 100644
--- a/client/public/privacy.html
+++ b/client/public/privacy.html
@@ -69,7 +69,7 @@
Thesis and application data
Assessment and grading data (feedback, grade suggestions, final grade)
Comments and communication within the thesis process
Presentation details (date, time, location, stream link)
-
Interview scheduling data (time slots, location, stream link)
+
Interview scheduling data (time slots, location, stream link) and interview assessment notes
Legal basis: Performance of the university's public task of organizing and assessing theses
(Art. 6(1)(e) GDPR).
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index 99f2ab69c..bf35c3eea 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -44,6 +44,17 @@ When a user requests deletion of their data, the response depends on the legal b
4. If the user has no active thesis and no retained examination data, offer full account deactivation.
5. Document the request and the actions taken for accountability purposes.
+## Prerequisite: Automatic Application Expiration
+
+The 1-year retention period for rejected applications (see above) requires that every application eventually receives a rejection date. Without this, unreviewed applications would remain in a "not assessed" state indefinitely, making data cleanup impossible and violating the data minimization principle.
+
+To address this, the application includes a time-based expiration mechanism that automatically rejects applications which have not been reviewed within a configurable period (configured per research group in weeks, with a minimum of 2 weeks). When triggered, the student receives the standard rejection email notification.
+
+**This is not automated decision-making** in the sense of GDPR Art. 22. It does not evaluate the applicant's qualifications, profile, or any personal characteristics. It is a simple timeout comparable to a deadline expiring. Its purposes are:
+
+1. **Student transparency**: Without expiration, students whose applications are never reviewed would wait indefinitely without any response. The automatic rejection ensures they are notified and can reapply or look for alternatives.
+2. **Data minimization**: The expiration assigns a rejection date, which starts the 1-year retention clock and enables eventual data cleanup.
+
## Implementation Status
- [ ] Automatic deletion of rejected applications after 1 year (not yet implemented)
From ba621fcb89491887e205db4a5b755893c461b041 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 11:38:46 +0100
Subject: [PATCH 05/39] Prioritize implementation TODOs in data retention
documentation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace flat checklist with prioritized sections. Add configurable
application email content as highest priority item based on feedback
from Lehrstuhl für Mikro- und Nanosystemtechnik. Include CalDAV
removal and migration cleanup as low-priority items.
Co-Authored-By: Claude Opus 4.6
---
docs/DATA_RETENTION.md | 25 +++++++++++++++++++------
1 file changed, 19 insertions(+), 6 deletions(-)
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index bf35c3eea..445e68738 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -55,10 +55,23 @@ To address this, the application includes a time-based expiration mechanism that
1. **Student transparency**: Without expiration, students whose applications are never reviewed would wait indefinitely without any response. The automatic rejection ensures they are notified and can reapply or look for alternatives.
2. **Data minimization**: The expiration assigns a rejection date, which starts the 1-year retention clock and enables eventual data cleanup.
-## Implementation Status
+## Implementation TODO
-- [ ] Automatic deletion of rejected applications after 1 year (not yet implemented)
-- [ ] Automatic deletion/archival of thesis data after 5-year retention period (not yet implemented)
-- [ ] Automatic disabling of inactive accounts after 1 year of inactivity (not yet implemented)
-- [ ] Deletion of disabled user accounts after linked data retention periods expire (not yet implemented)
-- [ ] Reactivation of disabled accounts on login (not yet implemented)
+Prioritized by urgency and impact on GDPR compliance.
+
+### Priority 1 — High (address before next complaint)
+
+- [ ] **Configurable application email content**: Add a per-research-group setting to control whether application notification emails include attachments (CV, examination report) and personal details, or only contain the student name, topic, and a link to the application in the system. This addresses a data privacy concern raised by the Lehrstuhl für Mikro- und Nanosystemtechnik about the inability to retract personal data from already-sent emails. Research groups with strict privacy requirements can disable attachments; others can keep the current behavior.
+- [ ] **Automatic disabling of inactive accounts after 1 year of inactivity**: Required to fulfill the retention promise in the privacy statement. Without this, user data is retained indefinitely.
+- [ ] **Reactivation of disabled accounts on login**: Necessary counterpart to the above — disabled users who log in again should have their account re-enabled automatically.
+
+### Priority 2 — Medium (implement within next months)
+
+- [ ] **Automatic deletion of rejected applications after 1 year**: The privacy statement promises this retention period. Without enforcement, rejected application data accumulates indefinitely.
+- [ ] **Deletion of disabled user accounts after linked data retention periods expire**: Completes the account lifecycle — once all linked thesis/application data has been cleaned up, the account itself should be removed.
+
+### Priority 3 — Low (implement when capacity allows)
+
+- [ ] **Automatic deletion/archival of thesis data after 5-year retention period**: Important for long-term compliance, but the 5-year clock means this is not urgent for recently created data. Can be implemented once the higher-priority items are in place.
+- [ ] **Remove CalDAV push code from codebase**: CalDAV is disabled in production and has no benefit over ICS subscription feeds. Removing it simplifies the codebase.
+- [ ] **Remove ProfilePictureMigration after successful production deployment**: One-time migration task that should be deleted once it has run successfully.
From 297af104583612726b51b839d386435525409ba4 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 11:39:48 +0100
Subject: [PATCH 06/39] Add GDPR compliance TODOs for consent tracking, data
export, and deletion
Add server-side consent tracking, privacy statement versioning,
account/data deletion endpoint, and data export endpoint to the
prioritized TODO list. Items from a previous review that are already
resolved (Gravatar, notification preferences, log retention, email
logging) are excluded.
Co-Authored-By: Claude Opus 4.6
---
docs/DATA_RETENTION.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index 445e68738..d7d371543 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -62,11 +62,15 @@ Prioritized by urgency and impact on GDPR compliance.
### Priority 1 — High (address before next complaint)
- [ ] **Configurable application email content**: Add a per-research-group setting to control whether application notification emails include attachments (CV, examination report) and personal details, or only contain the student name, topic, and a link to the application in the system. This addresses a data privacy concern raised by the Lehrstuhl für Mikro- und Nanosystemtechnik about the inability to retract personal data from already-sent emails. Research groups with strict privacy requirements can disable attachments; others can keep the current behavior.
+- [ ] **Server-side consent tracking**: Consent to the privacy statement is currently stored only in the browser's localStorage, which is not auditable. Store consent with a timestamp in the database so there is a record of when each user consented and to which version of the privacy statement.
+- [ ] **Privacy statement versioning**: Track which version of the privacy statement each user consented to. When the statement changes, re-prompt users for consent. This is a natural extension of server-side consent tracking.
- [ ] **Automatic disabling of inactive accounts after 1 year of inactivity**: Required to fulfill the retention promise in the privacy statement. Without this, user data is retained indefinitely.
- [ ] **Reactivation of disabled accounts on login**: Necessary counterpart to the above — disabled users who log in again should have their account re-enabled automatically.
### Priority 2 — Medium (implement within next months)
+- [ ] **Account/data deletion endpoint**: There is currently no way for users to delete their account or data. Add a self-service account deletion feature or at minimum an admin endpoint to fully delete a user's data (Art. 17 right to erasure). Must respect the retention periods defined above (e.g. thesis data cannot be deleted before the 5-year period expires).
+- [ ] **Data export endpoint**: Add a GDPR data export feature (Art. 20 data portability) that lets users download all their personal data in a structured format (e.g. JSON/ZIP with profile data and uploaded files).
- [ ] **Automatic deletion of rejected applications after 1 year**: The privacy statement promises this retention period. Without enforcement, rejected application data accumulates indefinitely.
- [ ] **Deletion of disabled user accounts after linked data retention periods expire**: Completes the account lifecycle — once all linked thesis/application data has been cleaned up, the account itself should be removed.
From b7f6a5879588cebb23cb56adaa9692c74c9f3763 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 11:45:21 +0100
Subject: [PATCH 07/39] Clarify server-side consent tracking TODO with
implementation details
Merge consent tracking and privacy statement versioning into a single
item. Document the current localStorage workaround (UX convenience for
pre-ticking the checkbox) and describe the target implementation with
User entity fields and version-based re-prompting.
Co-Authored-By: Claude Opus 4.6
---
docs/DATA_RETENTION.md | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index d7d371543..8907a6859 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -59,11 +59,10 @@ To address this, the application includes a time-based expiration mechanism that
Prioritized by urgency and impact on GDPR compliance.
-### Priority 1 — High (address before next complaint)
+### Priority 1 — High (address quickly)
- [ ] **Configurable application email content**: Add a per-research-group setting to control whether application notification emails include attachments (CV, examination report) and personal details, or only contain the student name, topic, and a link to the application in the system. This addresses a data privacy concern raised by the Lehrstuhl für Mikro- und Nanosystemtechnik about the inability to retract personal data from already-sent emails. Research groups with strict privacy requirements can disable attachments; others can keep the current behavior.
-- [ ] **Server-side consent tracking**: Consent to the privacy statement is currently stored only in the browser's localStorage, which is not auditable. Store consent with a timestamp in the database so there is a record of when each user consented and to which version of the privacy statement.
-- [ ] **Privacy statement versioning**: Track which version of the privacy statement each user consented to. When the statement changes, re-prompt users for consent. This is a natural extension of server-side consent tracking.
+- [ ] **Server-side consent tracking and privacy statement versioning**: The privacy notice consent checkbox ("I have read the privacy notice and agree to the processing of my data.") currently stores consent only in the browser's localStorage as a UX convenience — once checked and submitted, the checkbox stays pre-ticked on future profile edits so users don't have to re-check it every time. This is not auditable and not persistent across browsers. Replace with server-side tracking: add a `privacyConsentedAt` timestamp and `privacyVersion` field to the User entity. When the user checks the box and submits, store the consent on the server. On future visits, pre-tick the checkbox based on the server record instead of localStorage. When the privacy statement is updated (new version), clear the consent and re-prompt users to agree to the new version.
- [ ] **Automatic disabling of inactive accounts after 1 year of inactivity**: Required to fulfill the retention promise in the privacy statement. Without this, user data is retained indefinitely.
- [ ] **Reactivation of disabled accounts on login**: Necessary counterpart to the above — disabled users who log in again should have their account re-enabled automatically.
From e10962e6236032a33b46593fefc728ff4294b944 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 11:47:24 +0100
Subject: [PATCH 08/39] Reprioritize GDPR implementation TODOs
Move data export and automatic deletion of rejected applications to
highest priority as they address concrete GDPR rights (Art. 20, Art. 17)
that users can exercise today. Move configurable email content to medium
priority. Move consent tracking to low priority since there is no
versioned privacy statement yet and implicit consent through application
submission is sufficient for now.
Co-Authored-By: Claude Opus 4.6
---
docs/DATA_RETENTION.md | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index 8907a6859..bd9ee746f 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -61,20 +61,20 @@ Prioritized by urgency and impact on GDPR compliance.
### Priority 1 — High (address quickly)
-- [ ] **Configurable application email content**: Add a per-research-group setting to control whether application notification emails include attachments (CV, examination report) and personal details, or only contain the student name, topic, and a link to the application in the system. This addresses a data privacy concern raised by the Lehrstuhl für Mikro- und Nanosystemtechnik about the inability to retract personal data from already-sent emails. Research groups with strict privacy requirements can disable attachments; others can keep the current behavior.
-- [ ] **Server-side consent tracking and privacy statement versioning**: The privacy notice consent checkbox ("I have read the privacy notice and agree to the processing of my data.") currently stores consent only in the browser's localStorage as a UX convenience — once checked and submitted, the checkbox stays pre-ticked on future profile edits so users don't have to re-check it every time. This is not auditable and not persistent across browsers. Replace with server-side tracking: add a `privacyConsentedAt` timestamp and `privacyVersion` field to the User entity. When the user checks the box and submits, store the consent on the server. On future visits, pre-tick the checkbox based on the server record instead of localStorage. When the privacy statement is updated (new version), clear the consent and re-prompt users to agree to the new version.
-- [ ] **Automatic disabling of inactive accounts after 1 year of inactivity**: Required to fulfill the retention promise in the privacy statement. Without this, user data is retained indefinitely.
-- [ ] **Reactivation of disabled accounts on login**: Necessary counterpart to the above — disabled users who log in again should have their account re-enabled automatically.
+- [ ] **Data export endpoint**: Add a GDPR data export feature (Art. 20 data portability) that lets users download all their personal data in a structured format (e.g. JSON/ZIP with profile data and uploaded files). This is a concrete right users can exercise today — if someone requests their data and we cannot provide it, that is an immediate compliance gap.
+- [ ] **Automatic deletion of rejected applications after 1 year**: The privacy statement promises this retention period. Without enforcement, rejected application data accumulates indefinitely, creating a documented discrepancy between the privacy statement and actual behavior.
+- [ ] **Account/data deletion endpoint**: There is currently no way for users to delete their account or data. Add a self-service account deletion feature or at minimum an admin endpoint to fully delete a user's data (Art. 17 right to erasure). Must respect the retention periods defined above (e.g. thesis data cannot be deleted before the 5-year period expires).
### Priority 2 — Medium (implement within next months)
-- [ ] **Account/data deletion endpoint**: There is currently no way for users to delete their account or data. Add a self-service account deletion feature or at minimum an admin endpoint to fully delete a user's data (Art. 17 right to erasure). Must respect the retention periods defined above (e.g. thesis data cannot be deleted before the 5-year period expires).
-- [ ] **Data export endpoint**: Add a GDPR data export feature (Art. 20 data portability) that lets users download all their personal data in a structured format (e.g. JSON/ZIP with profile data and uploaded files).
-- [ ] **Automatic deletion of rejected applications after 1 year**: The privacy statement promises this retention period. Without enforcement, rejected application data accumulates indefinitely.
+- [ ] **Automatic disabling of inactive accounts after 1 year of inactivity**: Required to fulfill the retention promise in the privacy statement. Without this, user data is retained indefinitely.
+- [ ] **Reactivation of disabled accounts on login**: Necessary counterpart to the above — disabled users who log in again should have their account re-enabled automatically.
- [ ] **Deletion of disabled user accounts after linked data retention periods expire**: Completes the account lifecycle — once all linked thesis/application data has been cleaned up, the account itself should be removed.
+- [ ] **Configurable application email content**: Add a per-research-group setting to control whether application notification emails include attachments (CV, examination report) and personal details, or only contain the student name, topic, and a link to the application in the system. This addresses a data privacy concern raised by the Lehrstuhl für Mikro- und Nanosystemtechnik about the inability to retract personal data from already-sent emails. Research groups with strict privacy requirements can disable attachments; others can keep the current behavior.
### Priority 3 — Low (implement when capacity allows)
- [ ] **Automatic deletion/archival of thesis data after 5-year retention period**: Important for long-term compliance, but the 5-year clock means this is not urgent for recently created data. Can be implemented once the higher-priority items are in place.
+- [ ] **Server-side consent tracking and privacy statement versioning**: The privacy notice consent checkbox currently stores consent only in the browser's localStorage as a UX convenience — once checked and submitted, the checkbox stays pre-ticked on future profile edits so users don't have to re-check it every time. This is not auditable and not persistent across browsers. Replace with server-side tracking: add a `privacyConsentedAt` timestamp and `privacyVersion` field to the User entity. When the user checks the box and submits, store the consent on the server. On future visits, pre-tick the checkbox based on the server record instead of localStorage. When the privacy statement is updated (new version), clear the consent and re-prompt users to agree to the new version.
- [ ] **Remove CalDAV push code from codebase**: CalDAV is disabled in production and has no benefit over ICS subscription feeds. Removing it simplifies the codebase.
- [ ] **Remove ProfilePictureMigration after successful production deployment**: One-time migration task that should be deleted once it has run successfully.
From 8116d336fd25cf7b99942bd600e81ab91d5cd2b6 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 11:49:50 +0100
Subject: [PATCH 09/39] Move configurable email content to high priority
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Lehrstuhl für Mikro- und Nanosystemtechnik has already raised this
concern. Responding promptly demonstrates good faith; ignoring an active
complaint risks escalation.
Co-Authored-By: Claude Opus 4.6
---
docs/DATA_RETENTION.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index bd9ee746f..dde93c434 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -65,12 +65,13 @@ Prioritized by urgency and impact on GDPR compliance.
- [ ] **Automatic deletion of rejected applications after 1 year**: The privacy statement promises this retention period. Without enforcement, rejected application data accumulates indefinitely, creating a documented discrepancy between the privacy statement and actual behavior.
- [ ] **Account/data deletion endpoint**: There is currently no way for users to delete their account or data. Add a self-service account deletion feature or at minimum an admin endpoint to fully delete a user's data (Art. 17 right to erasure). Must respect the retention periods defined above (e.g. thesis data cannot be deleted before the 5-year period expires).
+- [ ] **Configurable application email content**: Add a per-research-group setting to control whether application notification emails include attachments (CV, examination report) and personal details, or only contain the student name, topic, and a link to the application in the system. This addresses an active data privacy concern raised by the Lehrstuhl für Mikro- und Nanosystemtechnik about the inability to retract personal data from already-sent emails. Responding promptly demonstrates good faith; ignoring it risks escalation to the DPO or BayLfD.
+
### Priority 2 — Medium (implement within next months)
- [ ] **Automatic disabling of inactive accounts after 1 year of inactivity**: Required to fulfill the retention promise in the privacy statement. Without this, user data is retained indefinitely.
- [ ] **Reactivation of disabled accounts on login**: Necessary counterpart to the above — disabled users who log in again should have their account re-enabled automatically.
- [ ] **Deletion of disabled user accounts after linked data retention periods expire**: Completes the account lifecycle — once all linked thesis/application data has been cleaned up, the account itself should be removed.
-- [ ] **Configurable application email content**: Add a per-research-group setting to control whether application notification emails include attachments (CV, examination report) and personal details, or only contain the student name, topic, and a link to the application in the system. This addresses a data privacy concern raised by the Lehrstuhl für Mikro- und Nanosystemtechnik about the inability to retract personal data from already-sent emails. Research groups with strict privacy requirements can disable attachments; others can keep the current behavior.
### Priority 3 — Low (implement when capacity allows)
From 82344779b9d137d230c7ae86308cb2a06f83c10a Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 11:56:16 +0100
Subject: [PATCH 10/39] Move data export endpoint to medium priority
Data export requests are currently handled manually, so there is no
immediate compliance gap. A self-service feature remains desirable to
reduce administrative effort.
Co-Authored-By: Claude Opus 4.6
---
docs/DATA_RETENTION.md | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index dde93c434..572679a2a 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -61,14 +61,13 @@ Prioritized by urgency and impact on GDPR compliance.
### Priority 1 — High (address quickly)
-- [ ] **Data export endpoint**: Add a GDPR data export feature (Art. 20 data portability) that lets users download all their personal data in a structured format (e.g. JSON/ZIP with profile data and uploaded files). This is a concrete right users can exercise today — if someone requests their data and we cannot provide it, that is an immediate compliance gap.
- [ ] **Automatic deletion of rejected applications after 1 year**: The privacy statement promises this retention period. Without enforcement, rejected application data accumulates indefinitely, creating a documented discrepancy between the privacy statement and actual behavior.
- [ ] **Account/data deletion endpoint**: There is currently no way for users to delete their account or data. Add a self-service account deletion feature or at minimum an admin endpoint to fully delete a user's data (Art. 17 right to erasure). Must respect the retention periods defined above (e.g. thesis data cannot be deleted before the 5-year period expires).
-
-- [ ] **Configurable application email content**: Add a per-research-group setting to control whether application notification emails include attachments (CV, examination report) and personal details, or only contain the student name, topic, and a link to the application in the system. This addresses an active data privacy concern raised by the Lehrstuhl für Mikro- und Nanosystemtechnik about the inability to retract personal data from already-sent emails. Responding promptly demonstrates good faith; ignoring it risks escalation to the DPO or BayLfD.
+- [ ] **Configurable application email content**: Add a per-research-group setting to control whether application notification emails include attachments (CV, examination report) and personal details, or only contain the student name, topic, and a link to the application in the system. This addresses a user request. Responding promptly demonstrates good faith.
### Priority 2 — Medium (implement within next months)
+- [ ] **Data export endpoint**: Add a GDPR data export feature (Art. 20 data portability) that lets users download all their personal data in a structured format (e.g. JSON/ZIP with profile data and uploaded files). Currently handled manually on request, but a self-service feature would reduce administrative effort and improve response time.
- [ ] **Automatic disabling of inactive accounts after 1 year of inactivity**: Required to fulfill the retention promise in the privacy statement. Without this, user data is retained indefinitely.
- [ ] **Reactivation of disabled accounts on login**: Necessary counterpart to the above — disabled users who log in again should have their account re-enabled automatically.
- [ ] **Deletion of disabled user accounts after linked data retention periods expire**: Completes the account lifecycle — once all linked thesis/application data has been cleaned up, the account itself should be removed.
From b5a19d2713a4a4e13f535f07c4aea3389bb2e91a Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 12:15:53 +0100
Subject: [PATCH 11/39] remove caldav support which is not used any more
---
.github/workflows/deploy_docker.yml | 10 +-
client/public/generate-runtime-env.js | 1 -
client/src/config/global.ts | 1 -
client/src/config/types.ts | 1 -
.../components/CalendarCarousel.tsx | 4 +-
.../PresentationOverviewPage.tsx | 4 +-
docker-compose.prod.yml | 4 -
docker-compose.yml | 6 -
docs/CONFIGURATION.md | 4 -
docs/DATA_RETENTION.md | 1 -
execute-e2e-local.sh | 2 +-
.../aet/thesis/entity/ThesisPresentation.java | 3 -
.../thesis/exception/CalendarException.java | 11 -
.../aet/thesis/service/CalendarService.java | 167 +---------
.../service/ThesisPresentationService.java | 26 --
.../cit/aet/thesis/service/ThesisService.java | 10 -
server/src/main/resources/application.yml | 5 -
.../changes/25_drop_calendar_event_column.sql | 4 +
.../db/changelog/db.changelog-master.xml | 1 +
.../service/CalendarServiceDisabledTest.java | 55 ----
.../CalendarServiceIntegrationTest.java | 300 ------------------
.../aet/thesis/service/ThesisServiceTest.java | 3 +-
22 files changed, 11 insertions(+), 612 deletions(-)
delete mode 100644 server/src/main/java/de/tum/cit/aet/thesis/exception/CalendarException.java
create mode 100644 server/src/main/resources/db/changelog/changes/25_drop_calendar_event_column.sql
delete mode 100644 server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceDisabledTest.java
delete mode 100644 server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceIntegrationTest.java
diff --git a/.github/workflows/deploy_docker.yml b/.github/workflows/deploy_docker.yml
index ec5b6d255..7e776e08e 100644
--- a/.github/workflows/deploy_docker.yml
+++ b/.github/workflows/deploy_docker.yml
@@ -112,10 +112,6 @@ jobs:
KEYCLOAK_SERVICE_CLIENT_ID: ${{ vars.KEYCLOAK_SERVICE_CLIENT_ID }}
KEYCLOAK_SERVICE_CLIENT_SECRET: ${{ secrets.KEYCLOAK_SERVICE_CLIENT_SECRET }}
KEYCLOAK_SERVICE_STUDENT_GROUP_NAME: ${{ vars.KEYCLOAK_SERVICE_STUDENT_GROUP_NAME }}
- CALDAV_ENABLED: ${{ vars.CALDAV_ENABLED }}
- CALDAV_URL: ${{ vars.CALDAV_URL }}
- CALDAV_USERNAME: ${{ vars.CALDAV_USERNAME }}
- CALDAV_PASSWORD: ${{ secrets.CALDAV_PASSWORD }}
with:
host: ${{ vars.VM_HOST }}
username: ${{ vars.VM_USERNAME }}
@@ -124,7 +120,7 @@ jobs:
proxy_username: ${{ vars.DEPLOYMENT_GATEWAY_USER }}
proxy_key: ${{ secrets.DEPLOYMENT_GATEWAY_SSH_KEY }}
proxy_port: ${{ vars.DEPLOYMENT_GATEWAY_PORT }}
- envs: SERVER_TAG,CLIENT_TAG,SPRING_DATASOURCE_DATABASE,SPRING_DATASOURCE_USERNAME,SPRING_DATASOURCE_PASSWORD,APP_HOSTNAME,SERVER_HOST,CLIENT_HOST,APPLICATION_TITLE,CHAIR_NAME,CHAIR_URL,DEFAULT_SUPERVISOR_UUID,ALLOW_SUGGESTED_TOPICS,THESIS_TYPES,STUDY_PROGRAMS,STUDY_DEGREES,GENDERS,LANGUAGES,CUSTOM_DATA,THESIS_FILES,SCIENTIFIC_WRITING_GUIDE,MAIL_SENDER,MAIL_SIGNATURE,MAIL_WORKSPACE_URL,KEYCLOAK_HOST,KEYCLOAK_REALM_NAME,KEYCLOAK_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_SECRET,KEYCLOAK_SERVICE_STUDENT_GROUP_NAME,CALDAV_ENABLED,CALDAV_URL,CALDAV_USERNAME,CALDAV_PASSWORD
+ envs: SERVER_TAG,CLIENT_TAG,SPRING_DATASOURCE_DATABASE,SPRING_DATASOURCE_USERNAME,SPRING_DATASOURCE_PASSWORD,APP_HOSTNAME,SERVER_HOST,CLIENT_HOST,APPLICATION_TITLE,CHAIR_NAME,CHAIR_URL,DEFAULT_SUPERVISOR_UUID,ALLOW_SUGGESTED_TOPICS,THESIS_TYPES,STUDY_PROGRAMS,STUDY_DEGREES,GENDERS,LANGUAGES,CUSTOM_DATA,THESIS_FILES,SCIENTIFIC_WRITING_GUIDE,MAIL_SENDER,MAIL_SIGNATURE,MAIL_WORKSPACE_URL,KEYCLOAK_HOST,KEYCLOAK_REALM_NAME,KEYCLOAK_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_SECRET,KEYCLOAK_SERVICE_STUDENT_GROUP_NAME
script: |
rm -f .env.prod
cat > .env.prod << ENVEOF
@@ -156,10 +152,6 @@ jobs:
KEYCLOAK_SERVICE_CLIENT_ID=${KEYCLOAK_SERVICE_CLIENT_ID}
KEYCLOAK_SERVICE_CLIENT_SECRET=${KEYCLOAK_SERVICE_CLIENT_SECRET}
KEYCLOAK_SERVICE_STUDENT_GROUP_NAME=${KEYCLOAK_SERVICE_STUDENT_GROUP_NAME}
- CALDAV_ENABLED=${CALDAV_ENABLED}
- CALDAV_URL=${CALDAV_URL}
- CALDAV_USERNAME=${CALDAV_USERNAME}
- CALDAV_PASSWORD=${CALDAV_PASSWORD}
SERVER_IMAGE_TAG=${SERVER_TAG:-latest}
CLIENT_IMAGE_TAG=${CLIENT_TAG:-latest}
ENVEOF
diff --git a/client/public/generate-runtime-env.js b/client/public/generate-runtime-env.js
index 3a1e9c225..accfccef3 100644
--- a/client/public/generate-runtime-env.js
+++ b/client/public/generate-runtime-env.js
@@ -18,7 +18,6 @@ const ALLOWED_ENVIRONMENT_VARIABLES = [
'THESIS_TYPES',
'CUSTOM_DATA',
'THESIS_FILES',
- 'CALDAV_URL',
]
async function generateConfig() {
diff --git a/client/src/config/global.ts b/client/src/config/global.ts
index 7c7c30cf3..c06d67e9f 100644
--- a/client/src/config/global.ts
+++ b/client/src/config/global.ts
@@ -104,7 +104,6 @@ export const GLOBAL_CONFIG: IGlobalConfig = {
},
default_supervisors: getEnvironmentVariable('DEFAULT_SUPERVISOR_UUID')?.split(';') || [],
- calendar_url: getEnvironmentVariable('CALDAV_URL') || '',
server_host: getEnvironmentVariable('SERVER_HOST') || 'http://localhost:8080',
keycloak: {
diff --git a/client/src/config/types.ts b/client/src/config/types.ts
index cbc99ee52..6c27f8f5b 100644
--- a/client/src/config/types.ts
+++ b/client/src/config/types.ts
@@ -44,7 +44,6 @@ export interface IGlobalConfig {
>
default_supervisors: string[]
- calendar_url: string
keycloak: {
client_id: string
diff --git a/client/src/pages/InterviewTopicOverviewPage/components/CalendarCarousel.tsx b/client/src/pages/InterviewTopicOverviewPage/components/CalendarCarousel.tsx
index a4e120d6b..629191398 100644
--- a/client/src/pages/InterviewTopicOverviewPage/components/CalendarCarousel.tsx
+++ b/client/src/pages/InterviewTopicOverviewPage/components/CalendarCarousel.tsx
@@ -119,9 +119,7 @@ const CalendarCarousel = ({ disabled = false }: ICalendarCarouselProps) => {
const user = useUser()
- const calendarUrl =
- GLOBAL_CONFIG.calendar_url ||
- `${GLOBAL_CONFIG.server_host}/api/v2/calendar/interviews/user/${user ? user.userId : ''}`
+ const calendarUrl = `${GLOBAL_CONFIG.server_host}/api/v2/calendar/interviews/user/${user ? user.userId : ''}`
return (
diff --git a/client/src/pages/PresentationOverviewPage/PresentationOverviewPage.tsx b/client/src/pages/PresentationOverviewPage/PresentationOverviewPage.tsx
index 41cc1df2c..cd3bbebc9 100644
--- a/client/src/pages/PresentationOverviewPage/PresentationOverviewPage.tsx
+++ b/client/src/pages/PresentationOverviewPage/PresentationOverviewPage.tsx
@@ -57,9 +57,7 @@ const PresentationOverviewPage = () => {
}
}, [context.researchGroups])
- const calendarUrl =
- GLOBAL_CONFIG.calendar_url ||
- `${GLOBAL_CONFIG.server_host}/api/v2/calendar/presentations${selectedGroup ? `/${selectedGroup.abbreviation}` : ''}`
+ const calendarUrl = `${GLOBAL_CONFIG.server_host}/api/v2/calendar/presentations${selectedGroup ? `/${selectedGroup.abbreviation}` : ''}`
const scrollRef = useRef(null)
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index cb8b1252c..75e38d90c 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -68,10 +68,6 @@ services:
- MAIL_WORKSPACE_URL
- MAIL_SENDER
- MAIL_SIGNATURE
- - CALDAV_ENABLED
- - CALDAV_URL
- - CALDAV_USERNAME
- - CALDAV_PASSWORD
- SCIENTIFIC_WRITING_GUIDE
networks:
- thesis-management-network
diff --git a/docker-compose.yml b/docker-compose.yml
index a1b0a9de1..0b718c149 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,12 +16,6 @@ services:
ports:
- "5144:5432"
- caldav:
- image: tomsquest/docker-radicale:3.6.0.0
- container_name: thesis-management-caldav
- ports:
- - "5232:5232"
-
keycloak:
image: quay.io/keycloak/keycloak:26.5
container_name: thesis-management-keycloak
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index da7119fde..aa37c0bf6 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -15,10 +15,6 @@ These are all environment variables that can be used to configure the applicatio
| KEYCLOAK_SERVICE_CLIENT_ID | server | | Keycloak service client id |
| KEYCLOAK_SERVICE_CLIENT_SECRET | server | | Keycloak service client secret |
| KEYCLOAK_SERVICE_STUDENT_GROUP_NAME | server | | Keycloak group name that should be assigned when a student starts writing a thesis |
-| CALDAV_ENABLED | server | false | Enable calendar integration. If enabled scheduled presentations will be added to the calendar |
-| CALDAV_URL | server, client | | CalDav URL where the events should be added |
-| CALDAV_USERNAME | server | | CalDav username for authentication |
-| CALDAV_PASSWORD | server | | CalDav password for authentication |
| POSTFIX_HOST | server | localhost | Postfix host to send emails. Only required if emails are enabled. |
| POSTFIX_PORT | server | 25 | Postfix port |
| POSTFIX_USERNAME | server | | Postfix username |
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index 572679a2a..5c381868f 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -76,5 +76,4 @@ Prioritized by urgency and impact on GDPR compliance.
- [ ] **Automatic deletion/archival of thesis data after 5-year retention period**: Important for long-term compliance, but the 5-year clock means this is not urgent for recently created data. Can be implemented once the higher-priority items are in place.
- [ ] **Server-side consent tracking and privacy statement versioning**: The privacy notice consent checkbox currently stores consent only in the browser's localStorage as a UX convenience — once checked and submitted, the checkbox stays pre-ticked on future profile edits so users don't have to re-check it every time. This is not auditable and not persistent across browsers. Replace with server-side tracking: add a `privacyConsentedAt` timestamp and `privacyVersion` field to the User entity. When the user checks the box and submits, store the consent on the server. On future visits, pre-tick the checkbox based on the server record instead of localStorage. When the privacy statement is updated (new version), clear the consent and re-prompt users to agree to the new version.
-- [ ] **Remove CalDAV push code from codebase**: CalDAV is disabled in production and has no benefit over ICS subscription feeds. Removing it simplifies the codebase.
- [ ] **Remove ProfilePictureMigration after successful production deployment**: One-time migration task that should be deleted once it has run successfully.
diff --git a/execute-e2e-local.sh b/execute-e2e-local.sh
index 66bf01344..8f33afa5b 100755
--- a/execute-e2e-local.sh
+++ b/execute-e2e-local.sh
@@ -137,7 +137,7 @@ for arg in "$@"; do
done
# ---------------------------------------------------------------------------
-# 1. Docker services (PostgreSQL + Keycloak + CalDAV)
+# 1. Docker services (PostgreSQL + Keycloak)
# ---------------------------------------------------------------------------
# These are long-lived infrastructure services that don't change with code
# edits, so we only ensure they are running (docker compose up is idempotent).
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/entity/ThesisPresentation.java b/server/src/main/java/de/tum/cit/aet/thesis/entity/ThesisPresentation.java
index 8ab8096a4..ba9b44c69 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/entity/ThesisPresentation.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/entity/ThesisPresentation.java
@@ -70,9 +70,6 @@ public class ThesisPresentation {
@Column(name = "presentation_note_html")
private String presentationNoteHtml;
- @Column(name = "calendar_event")
- private String calendarEvent;
-
@NotNull
@Column(name = "scheduled_at", nullable = false)
private Instant scheduledAt;
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/exception/CalendarException.java b/server/src/main/java/de/tum/cit/aet/thesis/exception/CalendarException.java
deleted file mode 100644
index bcce7a0fa..000000000
--- a/server/src/main/java/de/tum/cit/aet/thesis/exception/CalendarException.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package de.tum.cit.aet.thesis.exception;
-
-public class CalendarException extends RuntimeException {
- public CalendarException(String message) {
- super(message);
- }
-
- public CalendarException(String message, Throwable cause) {
- super(message, cause);
- }
-}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/CalendarService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/CalendarService.java
index ca0c9f104..8ff811a71 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/CalendarService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/CalendarService.java
@@ -1,9 +1,6 @@
package de.tum.cit.aet.thesis.service;
-import net.fortuna.ical4j.data.CalendarBuilder;
-import net.fortuna.ical4j.data.ParserException;
import net.fortuna.ical4j.model.Calendar;
-import net.fortuna.ical4j.model.Component;
import net.fortuna.ical4j.model.component.VEvent;
import net.fortuna.ical4j.model.parameter.PartStat;
import net.fortuna.ical4j.model.parameter.Role;
@@ -16,53 +13,17 @@
import net.fortuna.ical4j.model.property.Uid;
import net.fortuna.ical4j.model.property.immutable.ImmutableCalScale;
import net.fortuna.ical4j.model.property.immutable.ImmutableVersion;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.http.HttpMethod;
-import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
-import org.springframework.web.reactive.function.client.WebClient;
import jakarta.mail.internet.InternetAddress;
-import java.io.IOException;
-import java.io.Reader;
import java.net.URI;
import java.time.Instant;
import java.util.List;
-import java.util.Optional;
-import java.util.UUID;
-/** Manages calendar events via a CalDAV server, supporting creation, update, and deletion of iCal events. */
+/** Provides helper methods for building iCalendar feeds (ICS subscription format). */
@Service
public class CalendarService {
- private static final Logger log = LoggerFactory.getLogger(CalendarService.class);
-
- private final WebClient webClient;
- private final boolean enabled;
-
- /**
- * Initializes the CalDAV WebClient with the configured URL and authentication credentials.
- *
- * @param enabled whether the calendar integration is enabled
- * @param caldavUrl the CalDAV server URL
- * @param caldavUsername the CalDAV authentication username
- * @param caldavPassword the CalDAV authentication password
- */
- public CalendarService(
- @Value("${thesis-management.calendar.enabled}") Boolean enabled,
- @Value("${thesis-management.calendar.url}") String caldavUrl,
- @Value("${thesis-management.calendar.username}") String caldavUsername,
- @Value("${thesis-management.calendar.password}") String caldavPassword
- ) {
- this.enabled = enabled;
-
- this.webClient = WebClient.builder()
- .baseUrl(caldavUrl)
- .defaultHeaders(headers -> headers.setBasicAuth(caldavUsername, caldavPassword))
- .build();
- }
/**
* Represents a calendar event with scheduling details, organizer, and attendee information.
@@ -87,103 +48,6 @@ public record CalendarEvent(
List optionalAttendees
) {}
- /**
- * Creates a new calendar event on the CalDAV server and returns its generated event ID.
- *
- * @param data the calendar event data
- * @return the generated event ID, or null if calendar is disabled or creation fails
- */
- public String createEvent(CalendarEvent data) {
- if (!enabled) {
- return null;
- }
-
- try {
- Calendar calendar = getCalendar();
- String eventId = UUID.randomUUID().toString();
-
- calendar.add(createVEvent(eventId, data));
- updateCalendar(calendar);
-
- return eventId;
- } catch (Exception exception) {
- log.warn("Failed to create calendar event", exception);
- }
-
- return null;
- }
-
- /**
- * Updates an existing calendar event identified by its event ID with the provided data.
- *
- * @param eventId the event ID to update
- * @param data the new calendar event data
- */
- public void updateEvent(String eventId, CalendarEvent data) {
- if (!enabled) {
- return;
- }
-
- if (eventId == null) {
- return;
- }
-
- try {
- Calendar calendar = getCalendar();
- Optional event = findVEvent(calendar, eventId);
-
- event.ifPresent(calendar::remove);
- calendar.add(createVEvent(eventId, data));
-
- updateCalendar(calendar);
- } catch (Exception exception) {
- log.warn("Failed to create calendar event", exception);
- }
- }
-
- /**
- * Deletes a calendar event identified by its event ID from the CalDAV server.
- *
- * @param eventId the event ID to delete
- */
- public void deleteEvent(String eventId) {
- if (!enabled) {
- return;
- }
-
- if (eventId == null || eventId.isBlank()) {
- return;
- }
-
- try {
- Calendar calendar = getCalendar();
-
- VEvent event = findVEvent(calendar, eventId).orElseThrow();
- calendar.remove(event);
-
- updateCalendar(calendar);
- } catch (Exception exception) {
- log.warn("Failed to delete calendar event", exception);
- }
- }
-
- /**
- * Finds a VEvent in the given calendar by its unique event ID.
- *
- * @param calendar the iCal calendar to search
- * @param eventId the event ID to find
- * @return an optional containing the VEvent if found
- */
- public Optional findVEvent(Calendar calendar, String eventId) {
- return calendar.getComponents(Component.VEVENT).stream()
- .map(component -> (VEvent) component)
- .filter(event -> event.getUid()
- .map(Uid::getValue)
- .filter(value -> value.equals(eventId))
- .isPresent())
- .findFirst();
- }
-
/**
* Builds an iCal VEvent from the given event ID and calendar event data.
*
@@ -241,35 +105,6 @@ public VEvent createVEvent(String eventId, CalendarEvent data) {
return event;
}
- private Calendar getCalendar() {
- String response = webClient.method(HttpMethod.GET)
- .retrieve()
- .bodyToMono(String.class)
- .block();
-
- if (response == null) {
- throw new RuntimeException("Calendar response was empty");
- }
-
- try {
- CalendarBuilder builder = new CalendarBuilder();
- Reader reader = Reader.of(response);
-
- return builder.build(reader);
- } catch (IOException | ParserException e) {
- throw new RuntimeException("Failed to parse calendar", e);
- }
- }
-
- private void updateCalendar(Calendar calendar) {
- webClient.method(HttpMethod.PUT)
- .contentType(MediaType.parseMediaType("text/calendar"))
- .bodyValue(calendar.toString())
- .retrieve()
- .bodyToMono(Void.class)
- .block();
- }
-
/**
* Creates an empty iCal calendar with the specified product ID and Gregorian calendar scale.
*
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisPresentationService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisPresentationService.java
index daebccfda..1abb1d9cd 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisPresentationService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisPresentationService.java
@@ -251,8 +251,6 @@ public Thesis updatePresentation(
mailingService.sendScheduledPresentationEmail("UPDATED", presentation, getPresentationInvite(presentation).toString());
}
- updateThesisCalendarEvents(thesis);
-
return thesis;
}
@@ -289,12 +287,6 @@ public Thesis schedulePresentation(
presentation.setState(ThesisPresentationState.SCHEDULED);
- calendarService.deleteEvent(presentation.getCalendarEvent());
-
- if (presentation.getVisibility().equals(ThesisPresentationVisibility.PUBLIC)) {
- presentation.setCalendarEvent(calendarService.createEvent(createPresentationCalendarEvent(presentation)));
- }
-
presentation = thesisPresentationRepository.save(presentation);
Set addresses = new HashSet<>();
@@ -365,8 +357,6 @@ public Thesis deletePresentation(ThesisPresentation presentation) {
thesis = thesisRepository.save(thesis);
- calendarService.deleteEvent(presentation.getCalendarEvent());
-
if (presentation.getState() == ThesisPresentationState.SCHEDULED) {
mailingService.sendPresentationDeletedEmail(currentUserProvider().getUser(), presentation);
}
@@ -374,22 +364,6 @@ public Thesis deletePresentation(ThesisPresentation presentation) {
return thesis;
}
- /**
- * Updates all calendar events associated with the presentations of the given thesis.
- *
- * @param thesis the thesis whose presentation calendar events should be updated
- */
- public void updateThesisCalendarEvents(Thesis thesis) {
- currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
- for (ThesisPresentation presentation : thesis.getPresentations()) {
- String eventId = presentation.getCalendarEvent();
-
- if (eventId != null) {
- calendarService.updateEvent(eventId, createPresentationCalendarEvent(presentation));
- }
- }
- }
-
/**
* Finds a presentation by its ID and verifies it belongs to the specified thesis.
*
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java
index 588790f98..69c0f29c4 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java
@@ -71,7 +71,6 @@ public class ThesisService {
private final ThesisAssessmentRepository thesisAssessmentRepository;
private final MailingService mailingService;
private final AccessManagementService accessManagementService;
- private final ThesisPresentationService thesisPresentationService;
private final ThesisFeedbackRepository thesisFeedbackRepository;
private final ThesisFileRepository thesisFileRepository;
private final ObjectProvider currentUserProviderProvider;
@@ -90,7 +89,6 @@ public class ThesisService {
* @param uploadService the upload service for file storage
* @param mailingService the mailing service for sending notifications
* @param accessManagementService the access management service
- * @param thesisPresentationService the thesis presentation service
* @param thesisFeedbackRepository the thesis feedback repository
* @param thesisFileRepository the thesis file repository
* @param currentUserProviderProvider the provider for the current user context
@@ -108,7 +106,6 @@ public ThesisService(
UploadService uploadService,
MailingService mailingService,
AccessManagementService accessManagementService,
- ThesisPresentationService thesisPresentationService,
ThesisFeedbackRepository thesisFeedbackRepository, ThesisFileRepository thesisFileRepository,
ObjectProvider currentUserProviderProvider, ResearchGroupRepository researchGroupRepository, ResearchGroupSettingsService researchGroupSettingsService
) {
@@ -121,7 +118,6 @@ public ThesisService(
this.thesisAssessmentRepository = thesisAssessmentRepository;
this.mailingService = mailingService;
this.accessManagementService = accessManagementService;
- this.thesisPresentationService = thesisPresentationService;
this.thesisFeedbackRepository = thesisFeedbackRepository;
this.thesisFileRepository = thesisFileRepository;
this.currentUserProviderProvider = currentUserProviderProvider;
@@ -310,8 +306,6 @@ public Thesis updateThesis(
thesis = thesisRepository.save(thesis);
- thesisPresentationService.updateThesisCalendarEvents(thesis);
-
return thesis;
}
@@ -327,8 +321,6 @@ public Thesis updateThesisInfo(
thesis = thesisRepository.save(thesis);
- thesisPresentationService.updateThesisCalendarEvents(thesis);
-
return thesis;
}
@@ -351,8 +343,6 @@ public Thesis updateThesisTitles(
thesis = thesisRepository.save(thesis);
- thesisPresentationService.updateThesisCalendarEvents(thesis);
-
return thesis;
}
diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml
index fd575d801..cefb22d0f 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -81,11 +81,6 @@ thesis-management:
id: ${KEYCLOAK_SERVICE_CLIENT_ID:thesis-management-service-client}
secret: ${KEYCLOAK_SERVICE_CLIENT_SECRET:**********}
student-group-name: ${KEYCLOAK_SERVICE_STUDENT_GROUP_NAME:thesis-students}
- calendar:
- enabled: ${CALDAV_ENABLED:false}
- url: ${CALDAV_URL:}
- username: ${CALDAV_USERNAME:}
- password: ${CALDAV_PASSWORD:}
client:
host: ${CLIENT_HOST:http://localhost:3000}
mail:
diff --git a/server/src/main/resources/db/changelog/changes/25_drop_calendar_event_column.sql b/server/src/main/resources/db/changelog/changes/25_drop_calendar_event_column.sql
new file mode 100644
index 000000000..bfe8d5755
--- /dev/null
+++ b/server/src/main/resources/db/changelog/changes/25_drop_calendar_event_column.sql
@@ -0,0 +1,4 @@
+--liquibase formatted sql
+
+--changeset krusche:25-drop-calendar-event-column
+ALTER TABLE thesis_presentations DROP COLUMN IF EXISTS calendar_event;
diff --git a/server/src/main/resources/db/changelog/db.changelog-master.xml b/server/src/main/resources/db/changelog/db.changelog-master.xml
index d4362f0ec..fb3eaa296 100644
--- a/server/src/main/resources/db/changelog/db.changelog-master.xml
+++ b/server/src/main/resources/db/changelog/db.changelog-master.xml
@@ -28,4 +28,5 @@
+
diff --git a/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceDisabledTest.java b/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceDisabledTest.java
deleted file mode 100644
index 7ff7d77f5..000000000
--- a/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceDisabledTest.java
+++ /dev/null
@@ -1,55 +0,0 @@
-package de.tum.cit.aet.thesis.service;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import jakarta.mail.internet.InternetAddress;
-
-import java.time.Instant;
-import java.time.temporal.ChronoUnit;
-import java.util.List;
-
-class CalendarServiceDisabledTest {
-
- private CalendarService calendarService;
-
- @BeforeEach
- void setUp() {
- calendarService = new CalendarService(false, "http://localhost:9999", "user", "pass");
- }
-
- @Test
- void createEvent_WhenDisabled_ReturnsNull() throws Exception {
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Test", "Room", "Desc",
- Instant.now().plus(1, ChronoUnit.DAYS),
- Instant.now().plus(1, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES),
- new InternetAddress("org@test.com"),
- List.of(new InternetAddress("req@test.com")),
- List.of(new InternetAddress("opt@test.com"))
- );
-
- String result = calendarService.createEvent(data);
- assertThat(result).isNull();
- }
-
- @Test
- void updateEvent_WhenDisabled_ReturnsEarlyWithoutError() {
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Test", null, null,
- Instant.now().plus(1, ChronoUnit.DAYS),
- Instant.now().plus(1, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES),
- null, null, null
- );
-
- assertDoesNotThrow(() -> calendarService.updateEvent("some-id", data));
- }
-
- @Test
- void deleteEvent_WhenDisabled_ReturnsEarlyWithoutError() {
- assertDoesNotThrow(() -> calendarService.deleteEvent("some-id"));
- }
-}
diff --git a/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceIntegrationTest.java b/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceIntegrationTest.java
deleted file mode 100644
index 9578e37e3..000000000
--- a/server/src/test/java/de/tum/cit/aet/thesis/service/CalendarServiceIntegrationTest.java
+++ /dev/null
@@ -1,300 +0,0 @@
-package de.tum.cit.aet.thesis.service;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-
-import de.tum.cit.aet.thesis.mock.BaseIntegrationTest;
-import net.fortuna.ical4j.model.Calendar;
-import net.fortuna.ical4j.model.component.VEvent;
-import org.junit.jupiter.api.Nested;
-import org.junit.jupiter.api.Test;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.test.context.DynamicPropertyRegistry;
-import org.springframework.test.context.DynamicPropertySource;
-import org.testcontainers.junit.jupiter.Testcontainers;
-
-import jakarta.mail.internet.InternetAddress;
-
-import java.time.Instant;
-import java.time.temporal.ChronoUnit;
-import java.util.List;
-import java.util.Optional;
-
-@Testcontainers
-class CalendarServiceIntegrationTest extends BaseIntegrationTest {
-
- @DynamicPropertySource
- static void configureDynamicProperties(DynamicPropertyRegistry registry) {
- configureProperties(registry);
- }
-
- @Autowired
- private CalendarService calendarService;
-
- @Nested
- class CreateEvent {
- @Test
- void createEvent_WithFullData_ReturnsEventId() throws Exception {
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Test Presentation",
- "Room 101",
- "A test description",
- Instant.now().plus(1, ChronoUnit.DAYS),
- Instant.now().plus(1, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES),
- new InternetAddress("organizer@test.com"),
- List.of(new InternetAddress("required@test.com")),
- List.of(new InternetAddress("optional@test.com"))
- );
-
- String eventId = calendarService.createEvent(data);
- assertThat(eventId).isNotNull().isNotBlank();
- }
-
- @Test
- void createEvent_WithMinimalData_ReturnsEventId() throws Exception {
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Minimal Event",
- null,
- null,
- Instant.now().plus(2, ChronoUnit.DAYS),
- Instant.now().plus(2, ChronoUnit.DAYS).plus(60, ChronoUnit.MINUTES),
- null,
- null,
- null
- );
-
- String eventId = calendarService.createEvent(data);
- assertThat(eventId).isNotNull().isNotBlank();
- }
- }
-
- @Nested
- class UpdateEvent {
- @Test
- void updateEvent_ExistingEvent_Succeeds() throws Exception {
- CalendarService.CalendarEvent createData = new CalendarService.CalendarEvent(
- "Original Event",
- "Room A",
- "Original description",
- Instant.now().plus(3, ChronoUnit.DAYS),
- Instant.now().plus(3, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES),
- null, null, null
- );
-
- String eventId = calendarService.createEvent(createData);
- assertThat(eventId).isNotNull();
-
- CalendarService.CalendarEvent updateData = new CalendarService.CalendarEvent(
- "Updated Event",
- "Room B",
- "Updated description",
- Instant.now().plus(4, ChronoUnit.DAYS),
- Instant.now().plus(4, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES),
- null, null, null
- );
-
- assertDoesNotThrow(() -> calendarService.updateEvent(eventId, updateData));
- }
-
- @Test
- void updateEvent_NullEventId_ReturnsEarly() {
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Event", null, null,
- Instant.now().plus(1, ChronoUnit.DAYS),
- Instant.now().plus(1, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES),
- null, null, null
- );
-
- assertDoesNotThrow(() -> calendarService.updateEvent(null, data));
- }
- }
-
- @Nested
- class DeleteEvent {
- @Test
- void deleteEvent_ExistingEvent_Succeeds() throws Exception {
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "To Delete",
- null, null,
- Instant.now().plus(5, ChronoUnit.DAYS),
- Instant.now().plus(5, ChronoUnit.DAYS).plus(30, ChronoUnit.MINUTES),
- null, null, null
- );
-
- String eventId = calendarService.createEvent(data);
- assertThat(eventId).isNotNull();
-
- assertDoesNotThrow(() -> calendarService.deleteEvent(eventId));
- }
-
- @Test
- void deleteEvent_NullEventId_ReturnsEarly() {
- assertDoesNotThrow(() -> calendarService.deleteEvent(null));
- }
-
- @Test
- void deleteEvent_BlankEventId_ReturnsEarly() {
- assertDoesNotThrow(() -> calendarService.deleteEvent(""));
- }
-
- @Test
- void deleteEvent_NonExistentEvent_GracefulFailure() {
- assertDoesNotThrow(() -> calendarService.deleteEvent("non-existent-event-id"));
- }
- }
-
- @Nested
- class CreateVEvent {
- @Test
- void createVEvent_SetsUidAndTitle() throws Exception {
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Test Title",
- "Test Location",
- "Test Description",
- Instant.now(),
- Instant.now().plus(1, ChronoUnit.HOURS),
- null, null, null
- );
-
- VEvent event = calendarService.createVEvent("test-uid-123", data);
-
- assertThat(event.getUid().get().getValue()).isEqualTo("test-uid-123");
- assertThat(event.getSummary().getValue()).isEqualTo("Test Title");
- assertThat(event.getLocation().getValue()).isEqualTo("Test Location");
- assertThat(event.getDescription().getValue()).isEqualTo("Test Description");
- }
-
- @Test
- void createVEvent_WithOrganizer_SetsOrganizerProperty() throws Exception {
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Organizer Event",
- null, null,
- Instant.now(),
- Instant.now().plus(1, ChronoUnit.HOURS),
- new InternetAddress("organizer@test.com"),
- null, null
- );
-
- VEvent event = calendarService.createVEvent("org-uid", data);
-
- assertThat(event.getOrganizer().getValue()).contains("organizer@test.com");
- }
-
- @Test
- void createVEvent_WithRequiredAttendees_SetsAttendeeProperties() throws Exception {
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Attendee Event",
- null, null,
- Instant.now(),
- Instant.now().plus(1, ChronoUnit.HOURS),
- null,
- List.of(new InternetAddress("req@test.com")),
- null
- );
-
- VEvent event = calendarService.createVEvent("att-uid", data);
-
- assertThat(event.getProperties("ATTENDEE")).isNotEmpty();
- assertThat(event.getProperties("ATTENDEE").getFirst().getValue()).contains("req@test.com");
- }
-
- @Test
- void createVEvent_WithOptionalAttendees_SetsOptionalAttendeeProperties() throws Exception {
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Optional Attendee Event",
- null, null,
- Instant.now(),
- Instant.now().plus(1, ChronoUnit.HOURS),
- null,
- null,
- List.of(new InternetAddress("opt@test.com"))
- );
-
- VEvent event = calendarService.createVEvent("opt-uid", data);
-
- assertThat(event.getProperties("ATTENDEE")).isNotEmpty();
- }
-
- @Test
- void createVEvent_WithOverlappingAttendees_DeduplicatesOptional() throws Exception {
- InternetAddress shared = new InternetAddress("shared@test.com");
-
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Dedup Event",
- null, null,
- Instant.now(),
- Instant.now().plus(1, ChronoUnit.HOURS),
- null,
- List.of(shared),
- List.of(shared, new InternetAddress("unique@test.com"))
- );
-
- VEvent event = calendarService.createVEvent("dedup-uid", data);
-
- long sharedCount = event.getProperties("ATTENDEE").stream()
- .filter(p -> p.getValue().contains("shared@test.com"))
- .count();
- assertThat(sharedCount).isEqualTo(1);
- }
-
- @Test
- void createVEvent_WithNullLocationAndDescription_OmitsThoseFields() throws Exception {
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Sparse Event",
- null,
- null,
- Instant.now(),
- Instant.now().plus(1, ChronoUnit.HOURS),
- null, null, null
- );
-
- VEvent event = calendarService.createVEvent("sparse-uid", data);
-
- assertThat(event.getLocation()).isNull();
- assertThat(event.getDescription()).isNull();
- }
- }
-
- @Nested
- class FindVEvent {
- @Test
- void findVEvent_MatchingUid_ReturnsEvent() {
- Calendar calendar = calendarService.createEmptyCalendar("-//Test//Test//EN");
-
- CalendarService.CalendarEvent data = new CalendarService.CalendarEvent(
- "Find Me",
- null, null,
- Instant.now(),
- Instant.now().plus(1, ChronoUnit.HOURS),
- null, null, null
- );
-
- VEvent event = calendarService.createVEvent("find-uid", data);
- calendar.add(event);
-
- Optional found = calendarService.findVEvent(calendar, "find-uid");
- assertThat(found).isPresent();
- assertThat(found.get().getUid().get().getValue()).isEqualTo("find-uid");
- }
-
- @Test
- void findVEvent_NonMatchingUid_ReturnsEmpty() {
- Calendar calendar = calendarService.createEmptyCalendar("-//Test//Test//EN");
-
- Optional found = calendarService.findVEvent(calendar, "nonexistent-uid");
- assertThat(found).isEmpty();
- }
- }
-
- @Nested
- class CreateEmptyCalendar {
- @Test
- void createEmptyCalendar_SetsPropertiesCorrectly() {
- Calendar calendar = calendarService.createEmptyCalendar("-//Test//Calendar//EN");
-
- assertThat(calendar.toString()).contains("-//Test//Calendar//EN");
- assertThat(calendar.toString()).contains("VERSION:2.0");
- assertThat(calendar.toString()).contains("CALSCALE:GREGORIAN");
- }
- }
-}
diff --git a/server/src/test/java/de/tum/cit/aet/thesis/service/ThesisServiceTest.java b/server/src/test/java/de/tum/cit/aet/thesis/service/ThesisServiceTest.java
index e6a07e457..2688965c0 100644
--- a/server/src/test/java/de/tum/cit/aet/thesis/service/ThesisServiceTest.java
+++ b/server/src/test/java/de/tum/cit/aet/thesis/service/ThesisServiceTest.java
@@ -56,7 +56,6 @@ class ThesisServiceTest {
@Mock private ThesisAssessmentRepository thesisAssessmentRepository;
@Mock private MailingService mailingService;
@Mock private AccessManagementService accessManagementService;
- @Mock private ThesisPresentationService thesisPresentationService;
@Mock private ThesisFeedbackRepository thesisFeedbackRepository;
@Mock private ThesisFileRepository thesisFileRepository;
@Mock private ObjectProvider currentUserProviderProvider;
@@ -78,7 +77,7 @@ void setUp() {
thesisRoleRepository, thesisRepository, thesisStateChangeRepository,
userRepository, thesisProposalRepository, thesisAssessmentRepository,
uploadService, mailingService, accessManagementService,
- thesisPresentationService, thesisFeedbackRepository, thesisFileRepository,
+ thesisFeedbackRepository, thesisFileRepository,
currentUserProviderProvider, researchGroupRepository, researchGroupSettingsService
);
when(currentUserProviderProvider.getObject()).thenReturn(currentUserProvider);
From 68e837769ef6423bff78653371e9a8197e56763e Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 12:52:45 +0100
Subject: [PATCH 12/39] remove unused env variables
---
.github/workflows/deploy_docker.yml | 4 +---
client/public/generate-runtime-env.js | 1 -
client/src/config/global.ts | 1 -
client/src/config/types.ts | 2 --
docker-compose.prod.yml | 1 -
docs/CONFIGURATION.md | 1 -
6 files changed, 1 insertion(+), 9 deletions(-)
diff --git a/.github/workflows/deploy_docker.yml b/.github/workflows/deploy_docker.yml
index 7e776e08e..8f0f867be 100644
--- a/.github/workflows/deploy_docker.yml
+++ b/.github/workflows/deploy_docker.yml
@@ -93,7 +93,6 @@ jobs:
APPLICATION_TITLE: ${{ vars.APPLICATION_TITLE }}
CHAIR_NAME: ${{ vars.CHAIR_NAME }}
CHAIR_URL: ${{ vars.CHAIR_URL }}
- DEFAULT_SUPERVISOR_UUID: ${{ vars.DEFAULT_SUPERVISOR_UUID }}
ALLOW_SUGGESTED_TOPICS: ${{ vars.ALLOW_SUGGESTED_TOPICS }}
THESIS_TYPES: ${{ vars.THESIS_TYPES }}
STUDY_PROGRAMS: ${{ vars.STUDY_PROGRAMS }}
@@ -120,7 +119,7 @@ jobs:
proxy_username: ${{ vars.DEPLOYMENT_GATEWAY_USER }}
proxy_key: ${{ secrets.DEPLOYMENT_GATEWAY_SSH_KEY }}
proxy_port: ${{ vars.DEPLOYMENT_GATEWAY_PORT }}
- envs: SERVER_TAG,CLIENT_TAG,SPRING_DATASOURCE_DATABASE,SPRING_DATASOURCE_USERNAME,SPRING_DATASOURCE_PASSWORD,APP_HOSTNAME,SERVER_HOST,CLIENT_HOST,APPLICATION_TITLE,CHAIR_NAME,CHAIR_URL,DEFAULT_SUPERVISOR_UUID,ALLOW_SUGGESTED_TOPICS,THESIS_TYPES,STUDY_PROGRAMS,STUDY_DEGREES,GENDERS,LANGUAGES,CUSTOM_DATA,THESIS_FILES,SCIENTIFIC_WRITING_GUIDE,MAIL_SENDER,MAIL_SIGNATURE,MAIL_WORKSPACE_URL,KEYCLOAK_HOST,KEYCLOAK_REALM_NAME,KEYCLOAK_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_SECRET,KEYCLOAK_SERVICE_STUDENT_GROUP_NAME
+ envs: SERVER_TAG,CLIENT_TAG,SPRING_DATASOURCE_DATABASE,SPRING_DATASOURCE_USERNAME,SPRING_DATASOURCE_PASSWORD,APP_HOSTNAME,SERVER_HOST,CLIENT_HOST,APPLICATION_TITLE,CHAIR_NAME,CHAIR_URL,ALLOW_SUGGESTED_TOPICS,THESIS_TYPES,STUDY_PROGRAMS,STUDY_DEGREES,GENDERS,LANGUAGES,CUSTOM_DATA,THESIS_FILES,SCIENTIFIC_WRITING_GUIDE,MAIL_SENDER,MAIL_SIGNATURE,MAIL_WORKSPACE_URL,KEYCLOAK_HOST,KEYCLOAK_REALM_NAME,KEYCLOAK_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_SECRET,KEYCLOAK_SERVICE_STUDENT_GROUP_NAME
script: |
rm -f .env.prod
cat > .env.prod << ENVEOF
@@ -133,7 +132,6 @@ jobs:
APPLICATION_TITLE=${APPLICATION_TITLE}
CHAIR_NAME=${CHAIR_NAME}
CHAIR_URL=${CHAIR_URL}
- DEFAULT_SUPERVISOR_UUID=${DEFAULT_SUPERVISOR_UUID}
ALLOW_SUGGESTED_TOPICS=${ALLOW_SUGGESTED_TOPICS}
THESIS_TYPES=${THESIS_TYPES}
STUDY_PROGRAMS=${STUDY_PROGRAMS}
diff --git a/client/public/generate-runtime-env.js b/client/public/generate-runtime-env.js
index accfccef3..89be45e77 100644
--- a/client/public/generate-runtime-env.js
+++ b/client/public/generate-runtime-env.js
@@ -9,7 +9,6 @@ const ALLOWED_ENVIRONMENT_VARIABLES = [
'CHAIR_URL',
// environments with defaults
'ALLOW_SUGGESTED_TOPICS',
- 'DEFAULT_SUPERVISOR_UUID',
'APPLICATION_TITLE',
'GENDERS',
'STUDY_DEGREES',
diff --git a/client/src/config/global.ts b/client/src/config/global.ts
index c06d67e9f..321a4a5da 100644
--- a/client/src/config/global.ts
+++ b/client/src/config/global.ts
@@ -103,7 +103,6 @@ export const GLOBAL_CONFIG: IGlobalConfig = {
},
},
- default_supervisors: getEnvironmentVariable('DEFAULT_SUPERVISOR_UUID')?.split(';') || [],
server_host: getEnvironmentVariable('SERVER_HOST') || 'http://localhost:8080',
keycloak: {
diff --git a/client/src/config/types.ts b/client/src/config/types.ts
index 6c27f8f5b..27da3a14f 100644
--- a/client/src/config/types.ts
+++ b/client/src/config/types.ts
@@ -43,8 +43,6 @@ export interface IGlobalConfig {
}
>
- default_supervisors: string[]
-
keycloak: {
client_id: string
realm: string
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 75e38d90c..500d7e2bf 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -92,7 +92,6 @@ services:
- KEYCLOAK_REALM_NAME
- KEYCLOAK_CLIENT_ID
- ALLOW_SUGGESTED_TOPICS
- - DEFAULT_SUPERVISOR_UUID
- THESIS_TYPES
- STUDY_PROGRAMS
- STUDY_DEGREES
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index aa37c0bf6..e3d80f37c 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -35,6 +35,5 @@ These are all environment variables that can be used to configure the applicatio
| LANGUAGES | client | `{"ENGLISH":"English","GERMAN":"German"}` | Available languages for presentations |
| CUSTOM_DATA | client | `{"GITHUB":{"label":"Github Profile","required":false}}` | Additional data the user can add to the profile |
| THESIS_FILES | client | `{"PRESENTATION":{"label":"Presentation","description":"Presentation (PDF)","accept":"pdf","required":true},"PRESENTATION_SOURCE":{"label":"Presentation Source","description":"Presentation Source (KEY, PPTX)","accept":"any","required":false},"FEEDBACK_LOG":{"label":"Feedback Log","description":"Feedback Log (PDF)","accept":"pdf","required":false}}` | Additional files the student can add to the thesis |
-| DEFAULT_SUPERVISOR_UUID | client | | The user UUID from the database if a default supervisor should be selected when creating topics or theses |
| CHAIR_NAME | client | Thesis Management | Chair name |
| CHAIR_URL | client | window.origin | URL to chair website |
\ No newline at end of file
From 80ab20da25a0b9eaefbd180ac2ff4add65f3d448 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 13:07:26 +0100
Subject: [PATCH 13/39] allow scientific writing guidelines per research group,
remove unused global variables
---
.github/workflows/deploy_docker.yml | 8 +-
.../ResearchGroupSettingPage.tsx | 13 ++
.../ScientificWritingGuideSettingsCard.tsx | 123 ++++++++++++++++++
.../responses/researchGroupSettings.ts | 5 +
docker-compose.prod.yml | 3 -
docs/CONFIGURATION.md | 3 -
.../ResearchGroupSettingsController.java | 4 +
.../UpdateResearchGroupSettingsPayload.java | 4 +-
.../thesis/dto/ResearchGroupSettingsDTO.java | 6 +-
.../ResearchGroupSettingsWritingGuideDTO.java | 11 ++
.../thesis/entity/ResearchGroupSettings.java | 3 +
.../aet/thesis/service/DashboardService.java | 25 ++--
.../cit/aet/thesis/utility/MailConfig.java | 18 ---
server/src/main/resources/application.yml | 5 +-
...entific_writing_guide_link_to_settings.sql | 4 +
.../db/changelog/db.changelog-master.xml | 1 +
.../controller/DashboardControllerTest.java | 30 +++--
server/src/test/resources/application.yml | 10 +-
18 files changed, 207 insertions(+), 69 deletions(-)
create mode 100644 client/src/pages/ResearchGroupSettingPage/components/ScientificWritingGuideSettingsCard.tsx
create mode 100644 server/src/main/java/de/tum/cit/aet/thesis/dto/ResearchGroupSettingsWritingGuideDTO.java
create mode 100644 server/src/main/resources/db/changelog/changes/26_add_scientific_writing_guide_link_to_settings.sql
diff --git a/.github/workflows/deploy_docker.yml b/.github/workflows/deploy_docker.yml
index 8f0f867be..b4796ac71 100644
--- a/.github/workflows/deploy_docker.yml
+++ b/.github/workflows/deploy_docker.yml
@@ -101,10 +101,7 @@ jobs:
LANGUAGES: ${{ vars.LANGUAGES }}
CUSTOM_DATA: ${{ vars.CUSTOM_DATA }}
THESIS_FILES: ${{ vars.THESIS_FILES }}
- SCIENTIFIC_WRITING_GUIDE: ${{ vars.SCIENTIFIC_WRITING_GUIDE }}
MAIL_SENDER: ${{ vars.MAIL_SENDER }}
- MAIL_SIGNATURE: ${{ vars.MAIL_SIGNATURE }}
- MAIL_WORKSPACE_URL: ${{ vars.MAIL_WORKSPACE_URL }}
KEYCLOAK_HOST: ${{ vars.KEYCLOAK_HOST }}
KEYCLOAK_REALM_NAME: ${{ vars.KEYCLOAK_REALM_NAME }}
KEYCLOAK_CLIENT_ID: ${{ vars.KEYCLOAK_CLIENT_ID }}
@@ -119,7 +116,7 @@ jobs:
proxy_username: ${{ vars.DEPLOYMENT_GATEWAY_USER }}
proxy_key: ${{ secrets.DEPLOYMENT_GATEWAY_SSH_KEY }}
proxy_port: ${{ vars.DEPLOYMENT_GATEWAY_PORT }}
- envs: SERVER_TAG,CLIENT_TAG,SPRING_DATASOURCE_DATABASE,SPRING_DATASOURCE_USERNAME,SPRING_DATASOURCE_PASSWORD,APP_HOSTNAME,SERVER_HOST,CLIENT_HOST,APPLICATION_TITLE,CHAIR_NAME,CHAIR_URL,ALLOW_SUGGESTED_TOPICS,THESIS_TYPES,STUDY_PROGRAMS,STUDY_DEGREES,GENDERS,LANGUAGES,CUSTOM_DATA,THESIS_FILES,SCIENTIFIC_WRITING_GUIDE,MAIL_SENDER,MAIL_SIGNATURE,MAIL_WORKSPACE_URL,KEYCLOAK_HOST,KEYCLOAK_REALM_NAME,KEYCLOAK_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_SECRET,KEYCLOAK_SERVICE_STUDENT_GROUP_NAME
+ envs: SERVER_TAG,CLIENT_TAG,SPRING_DATASOURCE_DATABASE,SPRING_DATASOURCE_USERNAME,SPRING_DATASOURCE_PASSWORD,APP_HOSTNAME,SERVER_HOST,CLIENT_HOST,APPLICATION_TITLE,CHAIR_NAME,CHAIR_URL,ALLOW_SUGGESTED_TOPICS,THESIS_TYPES,STUDY_PROGRAMS,STUDY_DEGREES,GENDERS,LANGUAGES,CUSTOM_DATA,THESIS_FILES,MAIL_SENDER,KEYCLOAK_HOST,KEYCLOAK_REALM_NAME,KEYCLOAK_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_ID,KEYCLOAK_SERVICE_CLIENT_SECRET,KEYCLOAK_SERVICE_STUDENT_GROUP_NAME
script: |
rm -f .env.prod
cat > .env.prod << ENVEOF
@@ -140,10 +137,7 @@ jobs:
LANGUAGES=${LANGUAGES}
CUSTOM_DATA=${CUSTOM_DATA}
THESIS_FILES=${THESIS_FILES}
- SCIENTIFIC_WRITING_GUIDE=${SCIENTIFIC_WRITING_GUIDE}
MAIL_SENDER=${MAIL_SENDER}
- MAIL_SIGNATURE=${MAIL_SIGNATURE}
- MAIL_WORKSPACE_URL=${MAIL_WORKSPACE_URL}
KEYCLOAK_HOST=${KEYCLOAK_HOST}
KEYCLOAK_REALM_NAME=${KEYCLOAK_REALM_NAME}
KEYCLOAK_CLIENT_ID=${KEYCLOAK_CLIENT_ID}
diff --git a/client/src/pages/ResearchGroupSettingPage/ResearchGroupSettingPage.tsx b/client/src/pages/ResearchGroupSettingPage/ResearchGroupSettingPage.tsx
index ad3dbf945..de413d071 100644
--- a/client/src/pages/ResearchGroupSettingPage/ResearchGroupSettingPage.tsx
+++ b/client/src/pages/ResearchGroupSettingPage/ResearchGroupSettingPage.tsx
@@ -14,6 +14,7 @@ import { IResearchGroupSettings } from '../../requests/responses/researchGroupSe
import PresentationSettingsCard from './components/PresentationSettingsCard'
import ProposalSettingsCard from './components/ProposalSettingsCard'
import EmailSettingsCard from './components/EmailSettingsCard'
+import ScientificWritingGuideSettingsCard from './components/ScientificWritingGuideSettingsCard'
const ResearchGroupSettingPage = () => {
const { researchGroupId } = useParams<{ researchGroupId: string }>()
@@ -128,6 +129,18 @@ const ResearchGroupSettingPage = () => {
)
}
/>
+
+ setResearchGroupSettings(
+ (prev) =>
+ ({
+ ...prev,
+ writingGuideSettings: writingGuideSettings,
+ }) as IResearchGroupSettings,
+ )
+ }
+ />
{!researchGroupSettingsLoading && (
<>
void
+}
+
+const ScientificWritingGuideSettingsCard = ({
+ writingGuideSettings,
+ setWritingGuideSettings,
+}: ScientificWritingGuideSettingsCardProps) => {
+ const { researchGroupId } = useParams<{ researchGroupId: string }>()
+ const [saving, setSaving] = useState(false)
+
+ const form = useForm({
+ initialValues: {
+ scientificWritingGuideLink: writingGuideSettings?.scientificWritingGuideLink ?? '',
+ },
+ validateInputOnChange: true,
+ validate: {
+ scientificWritingGuideLink: (value) => {
+ const trimmed = value.trim()
+ if (!trimmed) return null
+ try {
+ new URL(trimmed)
+ return null
+ } catch {
+ return 'Enter a valid URL'
+ }
+ },
+ },
+ })
+
+ useEffect(() => {
+ form.setValues({
+ scientificWritingGuideLink: writingGuideSettings?.scientificWritingGuideLink ?? '',
+ })
+ }, [writingGuideSettings?.scientificWritingGuideLink])
+
+ const hasChanges =
+ (writingGuideSettings?.scientificWritingGuideLink ?? '') !==
+ form.values.scientificWritingGuideLink
+
+ const updateWritingGuideSettings = () => {
+ setSaving(true)
+ doRequest(
+ `/v2/research-group-settings/${researchGroupId}`,
+ {
+ method: 'POST',
+ requiresAuth: true,
+ data: {
+ writingGuideSettings: {
+ scientificWritingGuideLink: form.values.scientificWritingGuideLink.trim() || null,
+ },
+ },
+ },
+ (res) => {
+ setSaving(false)
+ if (res.ok) {
+ if (
+ res.data.writingGuideSettings.scientificWritingGuideLink !==
+ writingGuideSettings?.scientificWritingGuideLink
+ ) {
+ setWritingGuideSettings(res.data.writingGuideSettings)
+ }
+ showNotification({
+ title: 'Success',
+ message: 'Scientific writing guide settings updated successfully.',
+ color: 'green',
+ })
+ } else {
+ showSimpleError(getApiResponseErrorMessage(res))
+ }
+ },
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+export default ScientificWritingGuideSettingsCard
diff --git a/client/src/requests/responses/researchGroupSettings.ts b/client/src/requests/responses/researchGroupSettings.ts
index a10f3e50e..d94d31734 100644
--- a/client/src/requests/responses/researchGroupSettings.ts
+++ b/client/src/requests/responses/researchGroupSettings.ts
@@ -3,6 +3,7 @@ export interface IResearchGroupSettings {
presentationSettings: IResearchGroupSettingsPresentation
phaseSettings: IResearchGroupSettingsPhase
emailSettings: IResearchGroupSettingsEmail
+ writingGuideSettings: IResearchGroupSettingsWritingGuide
}
export interface IResearchGroupSettingsReject {
@@ -21,3 +22,7 @@ export interface IResearchGroupSettingsPhase {
export interface IResearchGroupSettingsEmail {
applicationNotificationEmail?: string | null
}
+
+export interface IResearchGroupSettingsWritingGuide {
+ scientificWritingGuideLink?: string | null
+}
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 500d7e2bf..a3e2ae3e4 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -65,10 +65,7 @@ services:
- KEYCLOAK_SERVICE_CLIENT_ID
- KEYCLOAK_SERVICE_CLIENT_SECRET
- KEYCLOAK_SERVICE_STUDENT_GROUP_NAME
- - MAIL_WORKSPACE_URL
- MAIL_SENDER
- - MAIL_SIGNATURE
- - SCIENTIFIC_WRITING_GUIDE
networks:
- thesis-management-network
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index e3d80f37c..674a7eed5 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -23,10 +23,7 @@ These are all environment variables that can be used to configure the applicatio
| SERVER_HOST | client | http://localhost:8080 | Hosting url of server |
| MAIL_ENABLED | server | false | If set to true, the application will try to send emails via Postfix |
| MAIL_SENDER | server | test@ios.ase.cit.tum.de | Sender email address |
-| MAIL_SIGNATURE | server | | Signature of the chair's supervisor / of the chair in general |
-| MAIL_WORKSPACE_URL | server | https://slack.com | URL to the workspace where students can connect with advisors and supervisors |
| UPLOAD_FOLDER | server | uploads | Folder where uploaded files will be stored |
-| SCIENTIFIC_WRITING_GUIDE | server | | Link to a guide that explains scientific writing at the chair |
| APPLICATION_TITLE | client | Thesis Management | HTML title of the client |
| GENDERS | client | `{"MALE":"Male","FEMALE":"Female","OTHER":"Other","PREFER_NOT_TO_SAY":"Prefer not to say"}` | Available genders that a user can configure |
| STUDY_DEGREES | client | `{"BACHELOR":"Bachelor","MASTER":"Master"}` | Available study degrees |
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/controller/ResearchGroupSettingsController.java b/server/src/main/java/de/tum/cit/aet/thesis/controller/ResearchGroupSettingsController.java
index 084013886..abedc61f8 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/controller/ResearchGroupSettingsController.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/controller/ResearchGroupSettingsController.java
@@ -81,6 +81,10 @@ public ResponseEntity createOrUpdateRejectSettings(@Pa
newSettings.emailSettings().applicationNotificationEmail() == null ? null : newSettings.emailSettings().applicationNotificationEmail().trim());
toSave.setApplicationNotificationEmail(validatedEmail);
}
+ if (newSettings.writingGuideSettings() != null) {
+ String link = newSettings.writingGuideSettings().scientificWritingGuideLink();
+ toSave.setScientificWritingGuideLink(link != null && !link.trim().isEmpty() ? link.trim() : null);
+ }
ResearchGroupSettings saved = service.saveOrUpdate(toSave);
return ResponseEntity.ok(ResearchGroupSettingsDTO.fromEntity(saved));
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/controller/payload/UpdateResearchGroupSettingsPayload.java b/server/src/main/java/de/tum/cit/aet/thesis/controller/payload/UpdateResearchGroupSettingsPayload.java
index dbea5c855..535c26374 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/controller/payload/UpdateResearchGroupSettingsPayload.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/controller/payload/UpdateResearchGroupSettingsPayload.java
@@ -4,12 +4,14 @@
import de.tum.cit.aet.thesis.dto.ResearchGroupSettingsPhasesDTO;
import de.tum.cit.aet.thesis.dto.ResearchGroupSettingsPresentationDTO;
import de.tum.cit.aet.thesis.dto.ResearchGroupSettingsRejectDTO;
+import de.tum.cit.aet.thesis.dto.ResearchGroupSettingsWritingGuideDTO;
public record UpdateResearchGroupSettingsPayload(
ResearchGroupSettingsRejectDTO rejectSettings,
ResearchGroupSettingsPresentationDTO presentationSettings,
ResearchGroupSettingsPhasesDTO phaseSettings,
- ResearchGroupSettingsEmailDTO emailSettings
+ ResearchGroupSettingsEmailDTO emailSettings,
+ ResearchGroupSettingsWritingGuideDTO writingGuideSettings
) {
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/dto/ResearchGroupSettingsDTO.java b/server/src/main/java/de/tum/cit/aet/thesis/dto/ResearchGroupSettingsDTO.java
index 81b49642d..1e1b078c8 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/dto/ResearchGroupSettingsDTO.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/dto/ResearchGroupSettingsDTO.java
@@ -6,14 +6,16 @@ public record ResearchGroupSettingsDTO(
ResearchGroupSettingsRejectDTO rejectSettings,
ResearchGroupSettingsPresentationDTO presentationSettings,
ResearchGroupSettingsPhasesDTO phaseSettings,
- ResearchGroupSettingsEmailDTO emailSettings
+ ResearchGroupSettingsEmailDTO emailSettings,
+ ResearchGroupSettingsWritingGuideDTO writingGuideSettings
) {
public static ResearchGroupSettingsDTO fromEntity(ResearchGroupSettings settings) {
return new ResearchGroupSettingsDTO(
ResearchGroupSettingsRejectDTO.fromEntity(settings),
ResearchGroupSettingsPresentationDTO.fromEntity(settings),
ResearchGroupSettingsPhasesDTO.fromEntity(settings),
- ResearchGroupSettingsEmailDTO.fromEntity(settings)
+ ResearchGroupSettingsEmailDTO.fromEntity(settings),
+ ResearchGroupSettingsWritingGuideDTO.fromEntity(settings)
);
}
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/dto/ResearchGroupSettingsWritingGuideDTO.java b/server/src/main/java/de/tum/cit/aet/thesis/dto/ResearchGroupSettingsWritingGuideDTO.java
new file mode 100644
index 000000000..691a6dd5d
--- /dev/null
+++ b/server/src/main/java/de/tum/cit/aet/thesis/dto/ResearchGroupSettingsWritingGuideDTO.java
@@ -0,0 +1,11 @@
+package de.tum.cit.aet.thesis.dto;
+
+import de.tum.cit.aet.thesis.entity.ResearchGroupSettings;
+
+public record ResearchGroupSettingsWritingGuideDTO(
+ String scientificWritingGuideLink
+) {
+ public static ResearchGroupSettingsWritingGuideDTO fromEntity(ResearchGroupSettings settings) {
+ return new ResearchGroupSettingsWritingGuideDTO(settings.getScientificWritingGuideLink());
+ }
+}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/entity/ResearchGroupSettings.java b/server/src/main/java/de/tum/cit/aet/thesis/entity/ResearchGroupSettings.java
index 38c19c465..db6391154 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/entity/ResearchGroupSettings.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/entity/ResearchGroupSettings.java
@@ -33,4 +33,7 @@ public class ResearchGroupSettings {
@Column(name = "application_notification_email")
private String applicationNotificationEmail;
+
+ @Column(name = "scientific_writing_guide_link")
+ private String scientificWritingGuideLink;
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/DashboardService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/DashboardService.java
index 5728b731a..59448f70f 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/DashboardService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/DashboardService.java
@@ -4,6 +4,8 @@
import de.tum.cit.aet.thesis.constants.ThesisRoleName;
import de.tum.cit.aet.thesis.constants.ThesisState;
import de.tum.cit.aet.thesis.dto.TaskDto;
+import de.tum.cit.aet.thesis.entity.ResearchGroup;
+import de.tum.cit.aet.thesis.entity.ResearchGroupSettings;
import de.tum.cit.aet.thesis.entity.Thesis;
import de.tum.cit.aet.thesis.entity.ThesisPresentation;
import de.tum.cit.aet.thesis.entity.User;
@@ -11,7 +13,6 @@
import de.tum.cit.aet.thesis.repository.ThesisRepository;
import de.tum.cit.aet.thesis.repository.TopicRepository;
import de.tum.cit.aet.thesis.security.CurrentUserProvider;
-import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
@@ -28,29 +29,25 @@ public class DashboardService {
private final ThesisRepository thesisRepository;
private final ApplicationRepository applicationRepository;
private final TopicRepository topicRepository;
- private final String scientificWritingGuide;
private final CurrentUserProvider currentUserProvider;
/**
- * Injects the thesis, application, and topic repositories along with the scientific writing guide URL.
+ * Injects the thesis, application, and topic repositories.
*
* @param thesisRepository the thesis repository
* @param applicationRepository the application repository
* @param topicRepository the topic repository
- * @param scientificWritingGuide the URL to the scientific writing guide
* @param currentUserProvider the current user provider
*/
public DashboardService(
ThesisRepository thesisRepository,
ApplicationRepository applicationRepository,
TopicRepository topicRepository,
- @Value("${thesis-management.scientific-writing-guide}") String scientificWritingGuide,
CurrentUserProvider currentUserProvider
) {
this.thesisRepository = thesisRepository;
this.applicationRepository = applicationRepository;
this.topicRepository = topicRepository;
- this.scientificWritingGuide = scientificWritingGuide;
this.currentUserProvider = currentUserProvider;
}
@@ -64,12 +61,16 @@ public List getTasks(User user) {
List tasks = new ArrayList<>();
UUID researchGroupId = user.getResearchGroup() != null ? user.getResearchGroup().getId() : null;
- if (user.hasAnyGroup("student") && !scientificWritingGuide.isBlank()) {
- tasks.add(new TaskDto(
- "Please make yourself familiar with scientific writing",
- scientificWritingGuide,
- 50
- ));
+ ResearchGroup researchGroup = user.getResearchGroup();
+ if (user.hasAnyGroup("student") && researchGroup != null) {
+ ResearchGroupSettings settings = researchGroup.getResearchGroupSettings();
+ if (settings != null && settings.getScientificWritingGuideLink() != null && !settings.getScientificWritingGuideLink().isBlank()) {
+ tasks.add(new TaskDto(
+ "Please make yourself familiar with scientific writing",
+ settings.getScientificWritingGuideLink(),
+ 50
+ ));
+ }
}
// general student tasks
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/utility/MailConfig.java b/server/src/main/java/de/tum/cit/aet/thesis/utility/MailConfig.java
index 7bae7f2af..2a1f31ffa 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/utility/MailConfig.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/utility/MailConfig.java
@@ -28,12 +28,6 @@ public class MailConfig {
@Getter
private final InternetAddress sender;
- @Getter
- private final String signature;
-
- @Getter
- private final String workspaceUrl;
-
@Getter
private final TemplateEngine templateEngine;
@@ -42,8 +36,6 @@ public class MailConfig {
*
* @param enabled whether email sending is enabled
* @param sender the sender email address
- * @param mailSignature the email signature
- * @param workspaceUrl the workspace URL
* @param clientHost the client host URL
* @param userRepository the user repository
* @param templateEngine the Thymeleaf template engine
@@ -52,16 +44,12 @@ public class MailConfig {
public MailConfig(
@Value("${thesis-management.mail.enabled}") boolean enabled,
@Value("${thesis-management.mail.sender}") InternetAddress sender,
- @Value("${thesis-management.mail.signature}") String mailSignature,
- @Value("${thesis-management.mail.workspace-url}") String workspaceUrl,
@Value("${thesis-management.client.host}") String clientHost,
UserRepository userRepository,
TemplateEngine templateEngine
) {
this.enabled = enabled;
this.sender = sender;
- this.workspaceUrl = workspaceUrl;
- this.signature = mailSignature;
this.clientHost = clientHost;
this.templateEngine = templateEngine;
@@ -100,13 +88,9 @@ public List getChairStudents(UUID researchGroupId) {
/**
* Data transfer object holding mail configuration values for use in email templates.
*
- * @param signature the email signature
- * @param workspaceUrl the workspace URL
* @param clientHost the client host URL
*/
public record MailConfigDto(
- String signature,
- String workspaceUrl,
String clientHost
) {}
@@ -117,8 +101,6 @@ public record MailConfigDto(
*/
public MailConfigDto getConfigDto() {
return new MailConfigDto(
- Objects.requireNonNullElse(signature, ""),
- Objects.requireNonNullElse(workspaceUrl, ""),
Objects.requireNonNullElse(getClientHost(), "")
);
}
diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml
index cefb22d0f..f9325aae3 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -85,9 +85,6 @@ thesis-management:
host: ${CLIENT_HOST:http://localhost:3000}
mail:
enabled: ${MAIL_ENABLED:false}
- sender: ${MAIL_SENDER:test@ios.ase.cit.tum.de}
- signature: ${MAIL_SIGNATURE:}
- workspace-url: ${MAIL_WORKSPACE_URL:https://slack.com}
+ sender: ${MAIL_SENDER:thesis-dev@test.aet.cit.tum.de}
storage:
upload-location: ${UPLOAD_FOLDER:uploads}
- scientific-writing-guide: ${SCIENTIFIC_WRITING_GUIDE:}
diff --git a/server/src/main/resources/db/changelog/changes/26_add_scientific_writing_guide_link_to_settings.sql b/server/src/main/resources/db/changelog/changes/26_add_scientific_writing_guide_link_to_settings.sql
new file mode 100644
index 000000000..c63bd902f
--- /dev/null
+++ b/server/src/main/resources/db/changelog/changes/26_add_scientific_writing_guide_link_to_settings.sql
@@ -0,0 +1,4 @@
+--liquibase formatted sql
+
+--changeset krusche:26-add-scientific-writing-guide-link-to-settings
+ALTER TABLE research_group_settings ADD COLUMN IF NOT EXISTS scientific_writing_guide_link VARCHAR(500);
diff --git a/server/src/main/resources/db/changelog/db.changelog-master.xml b/server/src/main/resources/db/changelog/db.changelog-master.xml
index fb3eaa296..d005121a2 100644
--- a/server/src/main/resources/db/changelog/db.changelog-master.xml
+++ b/server/src/main/resources/db/changelog/db.changelog-master.xml
@@ -29,4 +29,5 @@
+
diff --git a/server/src/test/java/de/tum/cit/aet/thesis/controller/DashboardControllerTest.java b/server/src/test/java/de/tum/cit/aet/thesis/controller/DashboardControllerTest.java
index d02ee8dd8..ee446ff7b 100644
--- a/server/src/test/java/de/tum/cit/aet/thesis/controller/DashboardControllerTest.java
+++ b/server/src/test/java/de/tum/cit/aet/thesis/controller/DashboardControllerTest.java
@@ -9,10 +9,12 @@
import de.tum.cit.aet.thesis.controller.payload.CreateApplicationPayload;
import de.tum.cit.aet.thesis.controller.payload.CreateThesisPayload;
import de.tum.cit.aet.thesis.controller.payload.ReplacePresentationPayload;
+import de.tum.cit.aet.thesis.entity.ResearchGroupSettings;
import de.tum.cit.aet.thesis.entity.Thesis;
import de.tum.cit.aet.thesis.entity.ThesisStateChange;
import de.tum.cit.aet.thesis.entity.key.ThesisStateChangeId;
import de.tum.cit.aet.thesis.mock.BaseIntegrationTest;
+import de.tum.cit.aet.thesis.repository.ResearchGroupSettingsRepository;
import de.tum.cit.aet.thesis.repository.ThesisRepository;
import de.tum.cit.aet.thesis.repository.ThesisStateChangeRepository;
import org.junit.jupiter.api.Nested;
@@ -44,6 +46,9 @@ static void configureDynamicProperties(DynamicPropertyRegistry registry) {
@Autowired
private ThesisStateChangeRepository thesisStateChangeRepository;
+ @Autowired
+ private ResearchGroupSettingsRepository researchGroupSettingsRepository;
+
private UUID createThesisWithState(String title, ThesisState targetState,
List students, List advisors, List supervisors, UUID researchGroupId) throws Exception {
CreateThesisPayload payload = new CreateThesisPayload(
@@ -90,7 +95,20 @@ private boolean hasTaskContaining(JsonNode tasks, String text) {
class StudentTasks {
@Test
void getTasks_AsStudent_ReturnsScientificWritingGuideTask() throws Exception {
- String auth = createRandomAuthentication("student");
+ TestUser student = createRandomTestUser(List.of("student"));
+ TestUser head = createRandomTestUser(List.of("supervisor"));
+ UUID researchGroupId = createTestResearchGroup("Writing Guide Group", head.universityId());
+
+ mockMvc.perform(MockMvcRequestBuilders.put("/v2/research-groups/{id}/assign/{username}", researchGroupId, student.universityId())
+ .header("Authorization", createRandomAdminAuthentication()))
+ .andExpect(status().isOk());
+
+ ResearchGroupSettings settings = new ResearchGroupSettings();
+ settings.setResearchGroupId(researchGroupId);
+ settings.setScientificWritingGuideLink("https://example.com/writing-guide");
+ researchGroupSettingsRepository.save(settings);
+
+ String auth = generateTestAuthenticationHeader(student.universityId(), List.of("student"));
String response = mockMvc.perform(MockMvcRequestBuilders.get("/v2/dashboard/tasks")
.header("Authorization", auth))
@@ -99,15 +117,7 @@ void getTasks_AsStudent_ReturnsScientificWritingGuideTask() throws Exception {
JsonNode json = objectMapper.readTree(response);
assertThat(json.size()).isGreaterThanOrEqualTo(1);
-
- boolean hasWritingGuide = false;
- for (JsonNode task : json) {
- if (task.get("message").asString().contains("scientific writing")) {
- hasWritingGuide = true;
- break;
- }
- }
- assertThat(hasWritingGuide).isTrue();
+ assertThat(hasTaskContaining(json, "scientific writing")).isTrue();
}
@Test
diff --git a/server/src/test/resources/application.yml b/server/src/test/resources/application.yml
index 73dd3ddb8..b5d24103d 100644
--- a/server/src/test/resources/application.yml
+++ b/server/src/test/resources/application.yml
@@ -52,18 +52,10 @@ thesis-management:
id: thesis-management-service-client
secret: ""
student-group-name: thesis-students
- calendar:
- enabled: true
- url: http://localhost:18080
- username: test
- password: test
client:
host: http://localhost:3000
mail:
enabled: true
- sender: test@ios.ase.cit.tum.de
- signature: ""
- workspace-url: https://slack.com
+ sender: thesis-dev@test.aet.cit.tum.de
storage:
upload-location: uploads
- scientific-writing-guide: http://localhost:3000/writing-guide
From 9f460b88b59e804c8bb121788f9a4270ccf16359 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 14:31:49 +0100
Subject: [PATCH 14/39] Add data retention UI controls, admin page, and E2E
tests
- Move DataRetentionCleanup to DataRetentionService (@Service, public API returning count)
- Add DELETE /v2/applications/{id} endpoint (admin-only) for individual application deletion
- Add POST /v2/data-retention/cleanup-rejected-applications endpoint for on-demand batch cleanup
- Add ApplicationDeleteButton component (admin-only, with confirmation modal)
- Add AdminPage with data retention panel and "Run Cleanup" button
- Add /admin route and Administration nav link for admins
- Add old rejected applications to seed data for E2E testing
- Add E2E tests for individual delete, batch cleanup, and non-admin restrictions
Co-Authored-By: Claude Opus 4.6
---
client/e2e/data-retention.spec.ts | 115 ++++++++++
client/src/app/Routes.tsx | 10 +
.../AuthenticatedArea/AuthenticatedArea.tsx | 7 +
.../ApplicationDeleteButton.tsx | 78 +++++++
client/src/pages/AdminPage/AdminPage.tsx | 63 ++++++
.../ApplicationReviewBody.tsx | 24 ++-
docs/CONFIGURATION.md | 2 +
docs/DATA_RETENTION.md | 2 +-
.../controller/ApplicationController.java | 9 +
.../controller/DataRetentionController.java | 29 +++
.../thesis/dto/DataRetentionResultDto.java | 4 +
.../de/tum/cit/aet/thesis/entity/User.java | 3 +
.../repository/ApplicationRepository.java | 11 +
.../thesis/service/ApplicationService.java | 6 +
.../thesis/service/AuthenticationService.java | 2 +
.../thesis/service/DataRetentionService.java | 60 ++++++
server/src/main/resources/application.yml | 3 +
.../changes/27_data_retention_preparation.sql | 11 +
.../db/changelog/db.changelog-master.xml | 1 +
.../changelog/manual/seed_dev_test_data.sql | 32 ++-
.../service/DataRetentionServiceTest.java | 199 ++++++++++++++++++
server/src/test/resources/application.yml | 3 +
22 files changed, 663 insertions(+), 11 deletions(-)
create mode 100644 client/e2e/data-retention.spec.ts
create mode 100644 client/src/components/ApplicationDeleteButton/ApplicationDeleteButton.tsx
create mode 100644 client/src/pages/AdminPage/AdminPage.tsx
create mode 100644 server/src/main/java/de/tum/cit/aet/thesis/controller/DataRetentionController.java
create mode 100644 server/src/main/java/de/tum/cit/aet/thesis/dto/DataRetentionResultDto.java
create mode 100644 server/src/main/java/de/tum/cit/aet/thesis/service/DataRetentionService.java
create mode 100644 server/src/main/resources/db/changelog/changes/27_data_retention_preparation.sql
create mode 100644 server/src/test/java/de/tum/cit/aet/thesis/service/DataRetentionServiceTest.java
diff --git a/client/e2e/data-retention.spec.ts b/client/e2e/data-retention.spec.ts
new file mode 100644
index 000000000..279a69f93
--- /dev/null
+++ b/client/e2e/data-retention.spec.ts
@@ -0,0 +1,115 @@
+import { test, expect } from '@playwright/test'
+import { authStatePath, navigateTo } from './helpers'
+
+const OLD_REJECTED_APPLICATION_ID = '00000000-0000-4000-c000-000000000009'
+const RECENT_REJECTED_APPLICATION_ID = '00000000-0000-4000-c000-000000000006'
+// NOT_ASSESSED application in ASE research group that the advisor can access
+const ADVISOR_VISIBLE_APPLICATION_ID = '00000000-0000-4000-c000-000000000004'
+
+test.describe('Data Retention - Admin Operations', () => {
+ test.use({ storageState: authStatePath('admin') })
+
+ // Run sequentially to avoid race conditions between delete and cleanup
+ test.describe.configure({ mode: 'serial' })
+
+ test('admin can delete an individual application', async ({ page }) => {
+ await navigateTo(page, `/applications/${OLD_REJECTED_APPLICATION_ID}`)
+
+ // The application may have been deleted in a prior test run; check if it loaded
+ const heading = page.getByRole('heading', { name: /Student2 User/i })
+ const hasApplication = await heading.isVisible({ timeout: 30_000 }).catch(() => false)
+ if (!hasApplication) {
+ // Application was already deleted in a prior run — skip gracefully
+ return
+ }
+
+ const deleteButton = page.getByRole('button', { name: 'Delete', exact: true })
+ await expect(deleteButton).toBeVisible({ timeout: 5_000 })
+ await deleteButton.click()
+
+ // Confirm in modal
+ await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 })
+ await expect(
+ page.getByRole('dialog').getByRole('heading', { name: 'Delete Application' }),
+ ).toBeVisible()
+ await expect(
+ page.getByText('Are you sure you want to permanently delete this application?'),
+ ).toBeVisible()
+
+ await page.getByRole('dialog').getByRole('button', { name: 'Delete Application' }).click()
+
+ // Wait for the dialog to close (indicates the delete request completed)
+ await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 15_000 })
+
+ // Verify navigation back to applications list (URL no longer contains the application UUID)
+ await expect(page).toHaveURL(/\/applications(?:\?|$)/, { timeout: 15_000 })
+ })
+
+ test('admin can trigger batch cleanup from admin page', async ({ page }) => {
+ await navigateTo(page, '/admin')
+
+ await expect(page.getByRole('heading', { name: 'Administration' })).toBeVisible({
+ timeout: 30_000,
+ })
+
+ await expect(page.getByRole('heading', { name: 'Data Retention' })).toBeVisible()
+
+ const cleanupButton = page.getByRole('button', { name: 'Run Cleanup' })
+ await expect(cleanupButton).toBeVisible()
+ await cleanupButton.click()
+
+ // Verify a success notification appears (either deleted count or no expired)
+ await expect(
+ page.getByText(/Deleted \d+ expired rejected application|No expired applications found/),
+ ).toBeVisible({ timeout: 15_000 })
+ })
+
+ test('recent rejected application survives cleanup', async ({ page }) => {
+ // First trigger cleanup
+ await navigateTo(page, '/admin')
+ await expect(page.getByRole('heading', { name: 'Administration' })).toBeVisible({
+ timeout: 30_000,
+ })
+ await page.getByRole('button', { name: 'Run Cleanup' }).click()
+ await expect(
+ page.getByText(/Deleted \d+ expired rejected application|No expired applications found/),
+ ).toBeVisible({ timeout: 15_000 })
+
+ // Now verify the recent rejected application still exists (admin can access DSA group)
+ await navigateTo(page, `/applications/${RECENT_REJECTED_APPLICATION_ID}`)
+ await expect(page.getByRole('heading', { name: /Student5 User/i })).toBeVisible({
+ timeout: 30_000,
+ })
+ })
+})
+
+test.describe('Data Retention - Non-Admin Restrictions', () => {
+ test.use({ storageState: authStatePath('advisor') })
+
+ test('advisor cannot see delete button on application', async ({ page }) => {
+ // Use an ASE application that the advisor can access
+ await navigateTo(page, `/applications/${ADVISOR_VISIBLE_APPLICATION_ID}`)
+
+ await expect(page.getByRole('heading', { name: /Student4 User/i })).toBeVisible({
+ timeout: 30_000,
+ })
+
+ // Delete button should not be visible for non-admin users
+ const deleteButton = page.getByRole('button', { name: 'Delete', exact: true })
+ await expect(deleteButton).not.toBeVisible({ timeout: 3_000 })
+ })
+
+ test('advisor cannot see admin page in navigation', async ({ page }) => {
+ await navigateTo(page, '/dashboard')
+
+ // Wait for page to load by checking for the dashboard content
+ await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({
+ timeout: 30_000,
+ })
+
+ // Administration link should not be in the nav (check by URL since nav may be collapsed)
+ await expect(page.locator('a[href="/admin"]')).not.toBeVisible({
+ timeout: 3_000,
+ })
+ })
+})
diff --git a/client/src/app/Routes.tsx b/client/src/app/Routes.tsx
index 8152b8fb8..ca8ba3222 100644
--- a/client/src/app/Routes.tsx
+++ b/client/src/app/Routes.tsx
@@ -42,6 +42,8 @@ const ReviewApplicationPage = lazy(
const ThesisPage = lazy(() => import('../pages/ThesisPage/ThesisPage'))
const LandingPage = lazy(() => import('../pages/LandingPage/LandingPage'))
+const AdminPage = lazy(() => import('../pages/AdminPage/AdminPage'))
+
const InterviewOverviewPage = lazy(
() => import('../pages/InterviewOverviewPage/InterviewOverviewPage'),
)
@@ -205,6 +207,14 @@ const AppRoutes = () => {
}
/>
+
+
+
+ }
+ />
) =>
icon: ChatsCircleIcon,
groups: ['advisor', 'supervisor'],
},
+ {
+ link: '/admin',
+ label: 'Administration',
+ icon: GearSix,
+ groups: ['admin'],
+ },
]
const user = useUser()
diff --git a/client/src/components/ApplicationDeleteButton/ApplicationDeleteButton.tsx b/client/src/components/ApplicationDeleteButton/ApplicationDeleteButton.tsx
new file mode 100644
index 000000000..898f09acf
--- /dev/null
+++ b/client/src/components/ApplicationDeleteButton/ApplicationDeleteButton.tsx
@@ -0,0 +1,78 @@
+import { doRequest } from '../../requests/request'
+import { IApplication } from '../../requests/responses/application'
+import { showSimpleError, showSimpleSuccess } from '../../utils/notification'
+import { Button, Modal, Stack, Text, type ButtonProps } from '@mantine/core'
+import React, { useState } from 'react'
+import { getApiResponseErrorMessage } from '../../requests/handler'
+import { useNavigate } from 'react-router'
+import { useAuthenticationContext } from '../../hooks/authentication'
+
+interface IApplicationDeleteButtonProps extends ButtonProps {
+ application: IApplication
+}
+
+const ApplicationDeleteButton = (props: IApplicationDeleteButtonProps) => {
+ const { application, ...buttonProps } = props
+
+ const auth = useAuthenticationContext()
+ const navigate = useNavigate()
+
+ const [confirmationModal, setConfirmationModal] = useState(false)
+ const [loading, setLoading] = useState(false)
+
+ if (!auth.user?.groups?.includes('admin')) {
+ return <>>
+ }
+
+ const onDelete = async () => {
+ setLoading(true)
+
+ try {
+ const response = await doRequest(`/v2/applications/${application.applicationId}`, {
+ method: 'DELETE',
+ requiresAuth: true,
+ })
+
+ if (response.ok) {
+ showSimpleSuccess('Application deleted successfully')
+ setConfirmationModal(false)
+ navigate('/applications')
+ } else {
+ showSimpleError(getApiResponseErrorMessage(response))
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ )
+}
+
+export default ApplicationDeleteButton
diff --git a/client/src/pages/AdminPage/AdminPage.tsx b/client/src/pages/AdminPage/AdminPage.tsx
new file mode 100644
index 000000000..594721a07
--- /dev/null
+++ b/client/src/pages/AdminPage/AdminPage.tsx
@@ -0,0 +1,63 @@
+import React, { useState } from 'react'
+import { Button, Card, Group, Stack, Text, Title } from '@mantine/core'
+import { doRequest } from '../../requests/request'
+import { showSimpleError, showSimpleSuccess } from '../../utils/notification'
+import { getApiResponseErrorMessage } from '../../requests/handler'
+
+interface IDataRetentionResult {
+ deletedApplications: number
+}
+
+const AdminPage = () => {
+ const [loading, setLoading] = useState(false)
+
+ const onRunCleanup = async () => {
+ setLoading(true)
+
+ try {
+ const response = await doRequest(
+ '/v2/data-retention/cleanup-rejected-applications',
+ {
+ method: 'POST',
+ requiresAuth: true,
+ },
+ )
+
+ if (response.ok) {
+ if (response.data.deletedApplications > 0) {
+ showSimpleSuccess(
+ `Deleted ${response.data.deletedApplications} expired rejected application(s)`,
+ )
+ } else {
+ showSimpleSuccess('No expired applications found')
+ }
+ } else {
+ showSimpleError(getApiResponseErrorMessage(response))
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ Administration
+
+
+ Data Retention
+
+ Manually trigger the data retention cleanup to permanently delete rejected applications
+ that have exceeded the configured retention period.
+
+
+
+ Run Cleanup
+
+
+
+
+
+ )
+}
+
+export default AdminPage
diff --git a/client/src/pages/ReviewApplicationPage/components/ApplicationReviewBody/ApplicationReviewBody.tsx b/client/src/pages/ReviewApplicationPage/components/ApplicationReviewBody/ApplicationReviewBody.tsx
index 663310a43..c0ec5e6b1 100644
--- a/client/src/pages/ReviewApplicationPage/components/ApplicationReviewBody/ApplicationReviewBody.tsx
+++ b/client/src/pages/ReviewApplicationPage/components/ApplicationReviewBody/ApplicationReviewBody.tsx
@@ -1,9 +1,10 @@
import ApplicationData from '../../../../components/ApplicationData/ApplicationData'
import ApplicationReviewForm from '../../../../components/ApplicationReviewForm/ApplicationReviewForm'
-import { Divider, Stack } from '@mantine/core'
+import { Divider, Group, Stack } from '@mantine/core'
import React, { useEffect } from 'react'
import { IApplication } from '../../../../requests/responses/application'
import ApplicationRejectButton from '../../../../components/ApplicationRejectButton/ApplicationRejectButton'
+import ApplicationDeleteButton from '../../../../components/ApplicationDeleteButton/ApplicationDeleteButton'
interface IApplicationReviewBodyProps {
application: IApplication
@@ -22,14 +23,19 @@ const ApplicationReviewBody = (props: IApplicationReviewBodyProps) => {
{
- onChange(newApplication)
- }}
- ml='auto'
- />
+
+
+ {
+ onChange(newApplication)
+ }}
+ />
+
}
bottomSection={
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 674a7eed5..70a18f128 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -32,5 +32,7 @@ These are all environment variables that can be used to configure the applicatio
| LANGUAGES | client | `{"ENGLISH":"English","GERMAN":"German"}` | Available languages for presentations |
| CUSTOM_DATA | client | `{"GITHUB":{"label":"Github Profile","required":false}}` | Additional data the user can add to the profile |
| THESIS_FILES | client | `{"PRESENTATION":{"label":"Presentation","description":"Presentation (PDF)","accept":"pdf","required":true},"PRESENTATION_SOURCE":{"label":"Presentation Source","description":"Presentation Source (KEY, PPTX)","accept":"any","required":false},"FEEDBACK_LOG":{"label":"Feedback Log","description":"Feedback Log (PDF)","accept":"pdf","required":false}}` | Additional files the student can add to the thesis |
+| DATA_RETENTION_CRON | server | 0 0 4 * * * | Cron expression for the nightly data retention cleanup job. Set to `-` to disable. |
+| REJECTED_APP_RETENTION_DAYS | server | 365 | Number of days to retain rejected applications before automatic deletion. |
| CHAIR_NAME | client | Thesis Management | Chair name |
| CHAIR_URL | client | window.origin | URL to chair website |
\ No newline at end of file
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index 5c381868f..890687025 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -61,7 +61,7 @@ Prioritized by urgency and impact on GDPR compliance.
### Priority 1 — High (address quickly)
-- [ ] **Automatic deletion of rejected applications after 1 year**: The privacy statement promises this retention period. Without enforcement, rejected application data accumulates indefinitely, creating a documented discrepancy between the privacy statement and actual behavior.
+- [x] **Automatic deletion of rejected applications after 1 year**: The privacy statement promises this retention period. Without enforcement, rejected application data accumulates indefinitely, creating a documented discrepancy between the privacy statement and actual behavior.
- [ ] **Account/data deletion endpoint**: There is currently no way for users to delete their account or data. Add a self-service account deletion feature or at minimum an admin endpoint to fully delete a user's data (Art. 17 right to erasure). Must respect the retention periods defined above (e.g. thesis data cannot be deleted before the 5-year period expires).
- [ ] **Configurable application email content**: Add a per-research-group setting to control whether application notification emails include attachments (CV, examination report) and personal details, or only contain the student name, topic, and a link to the application in the system. This addresses a user request. Responding promptly demonstrates good faith.
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/controller/ApplicationController.java b/server/src/main/java/de/tum/cit/aet/thesis/controller/ApplicationController.java
index 1f7cb2c34..af7132ec6 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/controller/ApplicationController.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/controller/ApplicationController.java
@@ -24,6 +24,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -34,6 +35,7 @@
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
+import java.util.Map;
import java.util.UUID;
/**
@@ -369,4 +371,11 @@ public ResponseEntity> rejectApplication(
applications.stream().map(item -> ApplicationDto.fromApplicationEntity(item, item.hasManagementAccess(authenticatedUser))).toList()
);
}
+
+ @DeleteMapping("/{applicationId}")
+ @PreAuthorize("hasRole('admin')")
+ public ResponseEntity
- {buttonProps.children ?? 'Delete'}
-
+ >
)
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/controller/UserInfoController.java b/server/src/main/java/de/tum/cit/aet/thesis/controller/UserInfoController.java
index e4b06814f..09c361260 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/controller/UserInfoController.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/controller/UserInfoController.java
@@ -9,6 +9,7 @@
import de.tum.cit.aet.thesis.entity.User;
import de.tum.cit.aet.thesis.repository.UserRepository;
import de.tum.cit.aet.thesis.service.AuthenticationService;
+import de.tum.cit.aet.thesis.service.GravatarService;
import de.tum.cit.aet.thesis.service.UploadService;
import de.tum.cit.aet.thesis.utility.RequestValidator;
import lombok.extern.slf4j.Slf4j;
@@ -25,31 +26,25 @@
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
-import java.io.InputStream;
-import java.net.URI;
-import java.net.http.HttpClient;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
-import java.security.MessageDigest;
-import java.time.Duration;
import java.util.List;
+import java.util.Optional;
/** REST controller for managing the authenticated user's profile and notification settings. */
@Slf4j
@RestController
@RequestMapping("/v2/user-info")
public class UserInfoController {
- private static final String AVATAR_LOOKUP_URL = "https://www.gravatar.com/avatar/";
-
private final AuthenticationService authenticationService;
private final UserRepository userRepository;
private final UploadService uploadService;
+ private final GravatarService gravatarService;
@Autowired
- public UserInfoController(AuthenticationService authenticationService, UserRepository userRepository, UploadService uploadService) {
+ public UserInfoController(AuthenticationService authenticationService, UserRepository userRepository, UploadService uploadService, GravatarService gravatarService) {
this.authenticationService = authenticationService;
this.userRepository = userRepository;
this.uploadService = uploadService;
+ this.gravatarService = gravatarService;
}
/**
@@ -168,51 +163,15 @@ public ResponseEntity importProfilePicture(JwtAuthenticationToken jwt)
return ResponseEntity.badRequest().build();
}
- try {
- String hash = sha256Hex(email.trim().toLowerCase());
- String lookupUrl = AVATAR_LOOKUP_URL + hash + "?s=400&d=404";
-
- HttpClient httpClient = HttpClient.newBuilder()
- .connectTimeout(Duration.ofSeconds(10))
- .followRedirects(HttpClient.Redirect.NORMAL)
- .build();
-
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(lookupUrl))
- .timeout(Duration.ofSeconds(10))
- .GET()
- .build();
-
- HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
-
- if (response.statusCode() != 200) {
- return ResponseEntity.notFound().build();
- }
-
- byte[] imageBytes = response.body().readAllBytes();
- String storedFilename = uploadService.storeBytes(imageBytes, "png", 1024 * 1024);
- user.setAvatar(storedFilename);
-
- user = userRepository.save(user);
-
- return ResponseEntity.ok(UserDto.fromUserEntity(user));
- } catch (Exception e) {
- log.warn("Failed to import profile picture for user {}", user.getId(), e);
- return ResponseEntity.internalServerError().build();
+ Optional imageBytes = gravatarService.fetchProfilePicture(email);
+ if (imageBytes.isEmpty()) {
+ return ResponseEntity.notFound().build();
}
- }
- private String sha256Hex(String input) {
- try {
- MessageDigest digest = MessageDigest.getInstance("SHA-256");
- byte[] hashBytes = digest.digest(input.getBytes());
- StringBuilder sb = new StringBuilder();
- for (byte b : hashBytes) {
- sb.append(String.format("%02x", b));
- }
- return sb.toString();
- } catch (Exception e) {
- throw new RuntimeException("SHA-256 not available", e);
- }
+ String storedFilename = uploadService.storeBytes(imageBytes.get(), "png", 1024 * 1024);
+ user.setAvatar(storedFilename);
+ user = userRepository.save(user);
+
+ return ResponseEntity.ok(UserDto.fromUserEntity(user));
}
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/cron/ProfilePictureMigration.java b/server/src/main/java/de/tum/cit/aet/thesis/cron/ProfilePictureMigration.java
index 1dee7ce19..b921c3f29 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/cron/ProfilePictureMigration.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/cron/ProfilePictureMigration.java
@@ -2,20 +2,15 @@
import de.tum.cit.aet.thesis.entity.User;
import de.tum.cit.aet.thesis.repository.UserRepository;
+import de.tum.cit.aet.thesis.service.GravatarService;
import de.tum.cit.aet.thesis.service.UploadService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
-import java.io.InputStream;
-import java.net.URI;
-import java.net.http.HttpClient;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
-import java.security.MessageDigest;
-import java.time.Duration;
import java.util.List;
+import java.util.Optional;
/**
* One-time migration task that attempts to fetch existing profile pictures for users
@@ -29,13 +24,13 @@
public class ProfilePictureMigration {
private static final Logger log = LoggerFactory.getLogger(ProfilePictureMigration.class);
- private static final String AVATAR_LOOKUP_URL = "https://www.gravatar.com/avatar/";
-
private final UserRepository userRepository;
+ private final GravatarService gravatarService;
private final UploadService uploadService;
- public ProfilePictureMigration(UserRepository userRepository, UploadService uploadService) {
+ public ProfilePictureMigration(UserRepository userRepository, GravatarService gravatarService, UploadService uploadService) {
this.userRepository = userRepository;
+ this.gravatarService = gravatarService;
this.uploadService = uploadService;
}
@@ -54,11 +49,6 @@ public void migrateProfilePictures() {
log.info("Profile picture migration: checking {} users without custom avatar", usersWithoutAvatar.size());
- HttpClient httpClient = HttpClient.newBuilder()
- .connectTimeout(Duration.ofSeconds(10))
- .followRedirects(HttpClient.Redirect.NORMAL)
- .build();
-
int downloaded = 0;
int skipped = 0;
@@ -70,21 +60,9 @@ public void migrateProfilePictures() {
continue;
}
- String hash = sha256Hex(email.trim().toLowerCase());
- String lookupUrl = AVATAR_LOOKUP_URL + hash + "?s=400&d=404";
-
- HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create(lookupUrl))
- .timeout(Duration.ofSeconds(10))
- .GET()
- .build();
-
- HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
-
- if (response.statusCode() == 200) {
- byte[] imageBytes = response.body().readAllBytes();
-
- String storedFilename = uploadService.storeBytes(imageBytes, "png", 1024 * 1024);
+ Optional imageBytes = gravatarService.fetchProfilePicture(email);
+ if (imageBytes.isPresent()) {
+ String storedFilename = uploadService.storeBytes(imageBytes.get(), "png", 1024 * 1024);
user.setAvatar(storedFilename);
userRepository.save(user);
downloaded++;
@@ -102,18 +80,4 @@ public void migrateProfilePictures() {
log.info("Profile picture migration completed: {} images downloaded, {} skipped", downloaded, skipped);
}
-
- private String sha256Hex(String input) {
- try {
- MessageDigest digest = MessageDigest.getInstance("SHA-256");
- byte[] hashBytes = digest.digest(input.getBytes());
- StringBuilder sb = new StringBuilder();
- for (byte b : hashBytes) {
- sb.append(String.format("%02x", b));
- }
- return sb.toString();
- } catch (Exception e) {
- throw new RuntimeException("SHA-256 not available", e);
- }
- }
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/dto/DataExportDto.java b/server/src/main/java/de/tum/cit/aet/thesis/dto/DataExportDto.java
index 2859facf1..97178ea64 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/dto/DataExportDto.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/dto/DataExportDto.java
@@ -14,7 +14,7 @@ public record DataExportDto(
Instant createdAt,
Instant creationFinishedAt,
Instant downloadedAt,
- boolean canRequest,
+ Boolean canRequest,
Instant nextRequestDate
) {
public static DataExportDto fromEntity(DataExport entity, boolean canRequest, Instant nextRequestDate) {
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/DataExportService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/DataExportService.java
index 516f5df38..ebcc8fe7a 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/DataExportService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/DataExportService.java
@@ -151,7 +151,12 @@ public Resource downloadDataExport(DataExport export, User user) {
throw new ResourceNotFoundException("Export file not found");
}
- FileSystemResource resource = new FileSystemResource(export.getFilePath());
+ Path resolvedPath = Path.of(export.getFilePath()).normalize();
+ if (!resolvedPath.startsWith(exportPath.normalize())) {
+ throw new ResourceNotFoundException("Export file not found");
+ }
+
+ FileSystemResource resource = new FileSystemResource(resolvedPath);
if (!resource.exists()) {
throw new ResourceNotFoundException("Export file not found on disk");
}
@@ -187,6 +192,29 @@ private void createDataExport(DataExport export) throws IOException {
String filename = String.format("export_%s_%d.zip", user.getId(), System.currentTimeMillis());
Path zipPath = exportPath.resolve(filename);
+ try {
+ writeZipFile(zipPath, user);
+ } catch (IOException e) {
+ // Clean up partial ZIP file on failure
+ Files.deleteIfExists(zipPath);
+ throw e;
+ }
+
+ export.setFilePath(zipPath.toString());
+ export.setCreationFinishedAt(Instant.now());
+
+ try {
+ mailingService.sendDataExportReadyEmail(user, export);
+ export.setState(DataExportState.EMAIL_SENT);
+ } catch (Exception e) {
+ log.warn("Failed to send data export email for export {}: {}", export.getId(), e.getMessage());
+ export.setState(DataExportState.EMAIL_FAILED);
+ }
+
+ dataExportRepository.save(export);
+ }
+
+ private void writeZipFile(Path zipPath, User user) throws IOException {
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipPath.toFile()))) {
// user.json
zos.putNextEntry(new ZipEntry("user.json"));
@@ -213,19 +241,6 @@ private void createDataExport(DataExport export) throws IOException {
addUserFile(zos, user.getDegreeFilename(), "files/degree_report");
addUserFile(zos, user.getExaminationFilename(), "files/examination_report");
}
-
- export.setFilePath(zipPath.toString());
- export.setCreationFinishedAt(Instant.now());
-
- try {
- mailingService.sendDataExportReadyEmail(user, export);
- export.setState(DataExportState.EMAIL_SENT);
- } catch (Exception e) {
- log.warn("Failed to send data export email for export {}: {}", export.getId(), e.getMessage());
- export.setState(DataExportState.EMAIL_FAILED);
- }
-
- dataExportRepository.save(export);
}
public void deleteExpiredExports() {
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/GravatarService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/GravatarService.java
new file mode 100644
index 000000000..64c389bd9
--- /dev/null
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/GravatarService.java
@@ -0,0 +1,79 @@
+package de.tum.cit.aet.thesis.service;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.security.MessageDigest;
+import java.time.Duration;
+import java.util.Optional;
+
+/**
+ * Fetches profile pictures from an external avatar service.
+ * Requests are made server-side so that the user's IP address is not exposed to the external service.
+ */
+@Service
+public class GravatarService {
+ private static final Logger log = LoggerFactory.getLogger(GravatarService.class);
+
+ private static final String AVATAR_LOOKUP_URL = "https://www.gravatar.com/avatar/";
+
+ /**
+ * Looks up a profile picture for the given email address.
+ *
+ * @param email the email address to look up
+ * @return the image bytes if a profile picture exists, empty otherwise
+ */
+ public Optional fetchProfilePicture(String email) {
+ if (email == null || email.isBlank()) {
+ return Optional.empty();
+ }
+
+ String hash = sha256Hex(email.trim().toLowerCase());
+ String lookupUrl = AVATAR_LOOKUP_URL + hash + "?s=400&d=404";
+
+ try (HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .build()) {
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(lookupUrl))
+ .timeout(Duration.ofSeconds(10))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
+
+ if (response.statusCode() != 200) {
+ return Optional.empty();
+ }
+
+ try (InputStream body = response.body()) {
+ return Optional.of(body.readAllBytes());
+ }
+ } catch (Exception e) {
+ log.warn("Failed to fetch profile picture for email hash {}: {}", hash, e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private static String sha256Hex(String input) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hashBytes = digest.digest(input.getBytes());
+ StringBuilder sb = new StringBuilder();
+ for (byte b : hashBytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ throw new RuntimeException("SHA-256 not available", e);
+ }
+ }
+}
From 723127dffd9d3d3e4abcb20c0bdc2a38914e4c29 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 18:26:32 +0100
Subject: [PATCH 17/39] Update documentation with new privacy, data export, and
retention features
- Add data export, profile picture import, and data retention to README
- Add scientific writing guide and admin features to README
- Add Data Retention Policy to developer documentation links
- Add DATA_EXPORT_PATH, DATA_EXPORT_RETENTION_DAYS, DATA_EXPORT_COOLDOWN_DAYS
environment variables to CONFIGURATION.md
- Mark data export TODO as completed in DATA_RETENTION.md
- Add data-exports volume and backup note to PRODUCTION.md
Co-Authored-By: Claude Opus 4.6
---
README.md | 40 +++++++++++++++++++++++++++++++++++++---
docs/CONFIGURATION.md | 3 +++
docs/DATA_RETENTION.md | 2 +-
docs/PRODUCTION.md | 5 ++++-
4 files changed, 45 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 4294302ba..f50a1e089 100644
--- a/README.md
+++ b/README.md
@@ -35,9 +35,15 @@ The videos are grouped by the roles student, supervisor, examiner, and research
- [Manage User Settings](https://live.rbg.tum.de/w/artemisintro/53605)
Enables students to configure their account settings, including personal information such as study program and contact details, ensuring all details are up-to-date.
-- [Book Interview Slot](https://live.rbg.tum.de/w/artemisintro/70067)
+- [Book Interview Slot](https://live.rbg.tum.de/w/artemisintro/70067)
Allows students to view available interview slots and book a preferred timeslot.
+- **Request Data Export**
+ Allows students to request an export of all their personal data (profile, applications, theses, uploaded files) as a ZIP file. Accessible from the Privacy page or directly at `/data-export`.
+
+- **Import Profile Picture**
+ Allows students to import their profile picture from Gravatar via the profile settings page. The lookup is performed server-side to protect the user's IP address.
+
#### Supervisor
- [Create Thesis Topic](https://live.rbg.tum.de/w/artemisintro/53599)
@@ -98,9 +104,20 @@ The videos are grouped by the roles student, supervisor, examiner, and research
- [Add Members to Research Group](https://live.rbg.tum.de/w/artemisintro/70056)
Shows how research group admins can add members to the research group.
-- [Make a Member Research Group Admin](https://live.rbg.tum.de/w/artemisintro/70055)
+- [Make a Member Research Group Admin](https://live.rbg.tum.de/w/artemisintro/70055)
Demonstrates how research group admins can grant admin permissions to a member.
+- **Configure Scientific Writing Guide**
+ Allows research group admins to set a custom link to scientific writing guidelines in the research group settings. This link is shown to students during the thesis writing phase.
+
+#### Admin
+
+- **Data Retention Management**
+ Admins can view data retention status and manually trigger the cleanup process from the Data Retention admin page. The nightly cleanup automatically deletes rejected applications older than 1 year and expired data export files.
+
+- **Delete Rejected Applications**
+ Admins can permanently delete rejected applications from the application detail page.
+
#### Thesis Page Permissions
Admins can view and edit all theses on the platform.
@@ -165,6 +182,7 @@ Group heads have the Group Admin role for their group by default (this cannot be
3. [Customizing E-Mails](docs/MAILS.md)
4. [Development Setup](docs/DEVELOPMENT.md) (includes [E2E Tests](docs/DEVELOPMENT.md#e2e-tests-playwright))
5. [Database Changes](docs/DATABASE.md)
+6. [Data Retention Policy](docs/DATA_RETENTION.md)
## Features
@@ -187,6 +205,22 @@ This mechanism ensures that students are not left waiting indefinitely for a res

+#### Privacy and Data Protection
+
+The platform includes GDPR-compliant privacy and data protection features:
+
+- **Privacy Statement**: A comprehensive privacy page accessible to all users (authenticated and unauthenticated) that documents all data processing activities, legal bases, retention periods, and data subject rights.
+- **Data Export (Art. 15 / Art. 20)**: Authenticated users can request an export of all their personal data from the Data Export page (also linked from the Privacy page). Exports are generated as ZIP files containing structured JSON data (profile, applications, theses, assessments) and uploaded documents (CV, degree report, examination report). Exports are processed overnight and the user receives an email notification with a link to download. Downloads are available for 7 days and users can request a new export every 7 days. See the [Data Retention Policy](docs/DATA_RETENTION.md) for details.
+- **Data Retention**: Automated cleanup of expired data runs nightly. Rejected applications are deleted after 1 year. Data export files are deleted after 7 days. Admins can trigger the cleanup manually from the Data Retention admin page. See the [Data Retention Policy](docs/DATA_RETENTION.md) for the full retention schedule and rationale.
+- **Application Deletion**: Admins can permanently delete rejected applications from the application detail page.
+- **Profile Picture Import**: Users can import their profile picture from Gravatar via their profile settings. The lookup is performed server-side to avoid exposing the user's IP address to external services.
+
+#### Research Group Settings
+
+Research group admins can configure per-group settings:
+
+- **Scientific Writing Guide**: A customizable link to scientific writing guidelines shown to students during the thesis writing phase. Each research group can configure its own link in the research group settings page.
+
> [!NOTE]
-> **Couldn't find what you were looking for?**
+> **Couldn't find what you were looking for?**
> If you need any further help or want to be onboarded to the system, reach out to us at **[thesis-management-support.aet@xcit.tum.de](thesis-management-support.aet@xcit.tum.de)**.
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 70a18f128..79a55dc52 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -34,5 +34,8 @@ These are all environment variables that can be used to configure the applicatio
| THESIS_FILES | client | `{"PRESENTATION":{"label":"Presentation","description":"Presentation (PDF)","accept":"pdf","required":true},"PRESENTATION_SOURCE":{"label":"Presentation Source","description":"Presentation Source (KEY, PPTX)","accept":"any","required":false},"FEEDBACK_LOG":{"label":"Feedback Log","description":"Feedback Log (PDF)","accept":"pdf","required":false}}` | Additional files the student can add to the thesis |
| DATA_RETENTION_CRON | server | 0 0 4 * * * | Cron expression for the nightly data retention cleanup job. Set to `-` to disable. |
| REJECTED_APP_RETENTION_DAYS | server | 365 | Number of days to retain rejected applications before automatic deletion. |
+| DATA_EXPORT_PATH | server | data-exports | Directory where data export ZIP files are stored. Should be backed up if persistent exports are needed. |
+| DATA_EXPORT_RETENTION_DAYS | server | 7 | Number of days to keep data export files before automatic deletion. |
+| DATA_EXPORT_COOLDOWN_DAYS | server | 7 | Minimum number of days between data export requests per user. |
| CHAIR_NAME | client | Thesis Management | Chair name |
| CHAIR_URL | client | window.origin | URL to chair website |
\ No newline at end of file
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index 890687025..8ef561810 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -67,7 +67,7 @@ Prioritized by urgency and impact on GDPR compliance.
### Priority 2 — Medium (implement within next months)
-- [ ] **Data export endpoint**: Add a GDPR data export feature (Art. 20 data portability) that lets users download all their personal data in a structured format (e.g. JSON/ZIP with profile data and uploaded files). Currently handled manually on request, but a self-service feature would reduce administrative effort and improve response time.
+- [x] **Data export endpoint**: Self-service GDPR data export feature (Art. 15 / Art. 20). Users can request an export from `/data-export`, which is processed overnight and generates a ZIP file containing profile data (JSON), applications, theses, assessments, and uploaded files. Users receive an email notification when ready. Downloads expire after 7 days, rate-limited to one request per 7 days.
- [ ] **Automatic disabling of inactive accounts after 1 year of inactivity**: Required to fulfill the retention promise in the privacy statement. Without this, user data is retained indefinitely.
- [ ] **Reactivation of disabled accounts on login**: Necessary counterpart to the above — disabled users who log in again should have their account re-enabled automatically.
- [ ] **Deletion of disabled user accounts after linked data retention periods expire**: Completes the account lifecycle — once all linked thesis/application data has been cleaned up, the account itself should be removed.
diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md
index 4c9d3cbee..c5baf454c 100644
--- a/docs/PRODUCTION.md
+++ b/docs/PRODUCTION.md
@@ -26,6 +26,7 @@ labels:
- "traefik.http.routers.server.priority=10"
volumes:
- ./thesis_uploads:/uploads
+ - ./thesis_data_exports:/data-exports
expose:
- "8080"
environment:
@@ -98,11 +99,13 @@ volumes:
## Backup Strategy
There are 2 places that require backups:
-- The PostgreSQL database. The backup strategy depends on the database setup, but the whole public schema of the connected database should be included in the backup.
+- The PostgreSQL database. The backup strategy depends on the database setup, but the whole public schema of the connected database should be included in the backup.
- Example backup command: `pg_dump -U thesismanagement --schema="public" thesismanagement > backup_thesismanagement.sql`
- Example import command: `psql -U thesismanagement -d thesismanagement -f backup_thesismanagement.sql`
- The files stored at `/uploads`. In the docker example, these files are mounted to `./thesis_uploads` and backup system should collect the files from the mounted folder
+Note: The `/data-exports` directory contains temporary data export ZIP files that auto-delete after 7 days. Backing up this directory is optional.
+
There is an example script [thesis-management-backup.sh](../supporting_scripts/thesis-management-backup.sh) that you can call in a cronjob to create regular backups.
## Further Configuration
From 5e1123cfa17344449bee60e4a16975cb1cfbad65 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 19:27:58 +0100
Subject: [PATCH 18/39] Automatically disable inactive student accounts after 1
year
Add a nightly job that identifies student accounts with no activity
(login, profile update, application, or data export) for over a year
and disables them. Users with active theses or non-student roles are
excluded. Disabled accounts are automatically re-enabled on login.
Co-Authored-By: Claude Opus 4.6
---
docs/CONFIGURATION.md | 1 +
docs/DATA_RETENTION.md | 4 +-
.../de/tum/cit/aet/thesis/entity/User.java | 3 +
.../aet/thesis/repository/UserRepository.java | 21 +++
.../thesis/service/AuthenticationService.java | 6 +
.../thesis/service/DataRetentionService.java | 41 +++++-
server/src/main/resources/application.yml | 1 +
.../changes/29_user_disabled_flag.sql | 4 +
.../db/changelog/db.changelog-master.xml | 1 +
.../service/DataRetentionServiceTest.java | 133 ++++++++++++++++++
server/src/test/resources/application.yml | 1 +
11 files changed, 213 insertions(+), 3 deletions(-)
create mode 100644 server/src/main/resources/db/changelog/changes/29_user_disabled_flag.sql
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 79a55dc52..3557eeb7c 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -37,5 +37,6 @@ These are all environment variables that can be used to configure the applicatio
| DATA_EXPORT_PATH | server | data-exports | Directory where data export ZIP files are stored. Should be backed up if persistent exports are needed. |
| DATA_EXPORT_RETENTION_DAYS | server | 7 | Number of days to keep data export files before automatic deletion. |
| DATA_EXPORT_COOLDOWN_DAYS | server | 7 | Minimum number of days between data export requests per user. |
+| INACTIVE_USER_DAYS | server | 365 | Number of days of inactivity after which student accounts are automatically disabled. |
| CHAIR_NAME | client | Thesis Management | Chair name |
| CHAIR_URL | client | window.origin | URL to chair website |
\ No newline at end of file
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index 8ef561810..d42fda8fe 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -68,8 +68,8 @@ Prioritized by urgency and impact on GDPR compliance.
### Priority 2 — Medium (implement within next months)
- [x] **Data export endpoint**: Self-service GDPR data export feature (Art. 15 / Art. 20). Users can request an export from `/data-export`, which is processed overnight and generates a ZIP file containing profile data (JSON), applications, theses, assessments, and uploaded files. Users receive an email notification when ready. Downloads expire after 7 days, rate-limited to one request per 7 days.
-- [ ] **Automatic disabling of inactive accounts after 1 year of inactivity**: Required to fulfill the retention promise in the privacy statement. Without this, user data is retained indefinitely.
-- [ ] **Reactivation of disabled accounts on login**: Necessary counterpart to the above — disabled users who log in again should have their account re-enabled automatically.
+- [x] **Automatic disabling of inactive accounts after 1 year of inactivity**: Required to fulfill the retention promise in the privacy statement. Without this, user data is retained indefinitely.
+- [x] **Reactivation of disabled accounts on login**: Necessary counterpart to the above — disabled users who log in again should have their account re-enabled automatically.
- [ ] **Deletion of disabled user accounts after linked data retention periods expire**: Completes the account lifecycle — once all linked thesis/application data has been cleaned up, the account itself should be removed.
### Priority 3 — Low (implement when capacity allows)
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java b/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java
index d0bd75e35..d3e4fb99b 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java
@@ -92,6 +92,9 @@ public class User {
@Column(name = "custom_data", columnDefinition = "jsonb")
private Map customData = new HashMap<>();
+ @Column(name = "disabled", nullable = false)
+ private boolean disabled = false;
+
@Column(name = "enrolled_at")
private Instant enrolledAt;
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java b/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java
index c2df7a58b..f9c3faf38 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java
@@ -8,6 +8,7 @@
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
+import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -65,4 +66,24 @@ AND t.state NOT IN ('FINISHED', 'DROPPED_OUT')
)
""")
List findStudentsWithActiveThesesByResearchGroupId(@Param("researchGroupId") UUID researchGroupId);
+
+ @Query("""
+ SELECT DISTINCT u FROM User u
+ JOIN UserGroup g ON u.id = g.id.userId AND g.id.group = 'student'
+ WHERE u.disabled = FALSE
+ AND COALESCE(u.lastLoginAt, u.joinedAt) < :cutoff
+ AND COALESCE(u.updatedAt, u.joinedAt) < :cutoff
+ AND NOT EXISTS (
+ SELECT 1 FROM UserGroup ug2
+ WHERE ug2.id.userId = u.id AND ug2.id.group IN ('admin', 'supervisor', 'advisor')
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM ThesisRole tr
+ WHERE tr.user.id = u.id AND tr.thesis.state NOT IN (
+ de.tum.cit.aet.thesis.constants.ThesisState.FINISHED,
+ de.tum.cit.aet.thesis.constants.ThesisState.DROPPED_OUT
+ )
+ )
+ """)
+ List findInactiveStudentCandidates(@Param("cutoff") Instant cutoff);
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/AuthenticationService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/AuthenticationService.java
index 8703fac2c..4c1bcb1ae 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/AuthenticationService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/AuthenticationService.java
@@ -68,6 +68,7 @@ public User getAuthenticatedUserWithResearchGroup(JwtAuthenticationToken jwt) {
.orElseThrow(() -> new ResourceNotFoundException("Authenticated user not found"));
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public User updateAuthenticatedUser(JwtAuthenticationToken jwt) {
Map attributes = jwt.getTokenAttributes();
@@ -112,6 +113,10 @@ public User updateAuthenticatedUser(JwtAuthenticationToken jwt) {
user.setLastLoginAt(Instant.now());
+ if (user.isDisabled()) {
+ user.setDisabled(false);
+ }
+
user = userRepository.save(user);
userGroupRepository.deleteByUserId(user.getId());
@@ -211,6 +216,7 @@ public List getNotificationSettings(User user) {
return user.getNotificationSettings();
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public List updateNotificationSettings(User user, String name, String email) {
List settings = user.getNotificationSettings();
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/DataRetentionService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/DataRetentionService.java
index 33d6d9e25..b2eb580bb 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/DataRetentionService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/DataRetentionService.java
@@ -1,6 +1,8 @@
package de.tum.cit.aet.thesis.service;
+import de.tum.cit.aet.thesis.entity.User;
import de.tum.cit.aet.thesis.repository.ApplicationRepository;
+import de.tum.cit.aet.thesis.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
@@ -17,24 +19,61 @@ public class DataRetentionService {
private static final Logger log = LoggerFactory.getLogger(DataRetentionService.class);
private final ApplicationRepository applicationRepository;
+ private final UserRepository userRepository;
private final DataExportService dataExportService;
private final int retentionDays;
+ private final int inactiveUserDays;
public DataRetentionService(ApplicationRepository applicationRepository,
+ UserRepository userRepository,
DataExportService dataExportService,
- @Value("${thesis-management.data-retention.rejected-application-retention-days}") int retentionDays) {
+ @Value("${thesis-management.data-retention.rejected-application-retention-days}") int retentionDays,
+ @Value("${thesis-management.data-retention.inactive-user-days}") int inactiveUserDays) {
this.applicationRepository = applicationRepository;
+ this.userRepository = userRepository;
this.dataExportService = dataExportService;
this.retentionDays = retentionDays;
+ this.inactiveUserDays = inactiveUserDays;
}
@Scheduled(cron = "${thesis-management.data-retention.cron}")
public void runNightlyCleanup() {
deleteExpiredRejectedApplications();
+ disableInactiveUsers();
dataExportService.processAllPendingExports();
dataExportService.deleteExpiredExports();
}
+ public int disableInactiveUsers() {
+ Instant cutoff = Instant.now().minus(inactiveUserDays, ChronoUnit.DAYS);
+
+ List candidates = userRepository.findInactiveStudentCandidates(cutoff);
+
+ List toDisable = candidates.stream()
+ .filter(user -> hasNoRecentActivity(user, cutoff))
+ .toList();
+
+ if (toDisable.isEmpty()) {
+ return 0;
+ }
+
+ for (User user : toDisable) {
+ user.setDisabled(true);
+ userRepository.save(user);
+ }
+
+ log.info("Disabled {} inactive student accounts (inactive for more than {} days)", toDisable.size(), inactiveUserDays);
+
+ return toDisable.size();
+ }
+
+ private boolean hasNoRecentActivity(User user, Instant cutoff) {
+ boolean hasRecentApplication = applicationRepository.findAllByUser(user).stream()
+ .anyMatch(app -> app.getCreatedAt().isAfter(cutoff));
+
+ return !hasRecentApplication;
+ }
+
public int deleteExpiredRejectedApplications() {
Instant cutoffDate = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml
index 7c18eec38..0c88d07d7 100644
--- a/server/src/main/resources/application.yml
+++ b/server/src/main/resources/application.yml
@@ -89,6 +89,7 @@ thesis-management:
data-retention:
cron: ${DATA_RETENTION_CRON:0 0 4 * * *}
rejected-application-retention-days: ${REJECTED_APP_RETENTION_DAYS:365}
+ inactive-user-days: ${INACTIVE_USER_DAYS:365}
data-export:
path: ${DATA_EXPORT_PATH:data-exports}
retention-days: ${DATA_EXPORT_RETENTION_DAYS:7}
diff --git a/server/src/main/resources/db/changelog/changes/29_user_disabled_flag.sql b/server/src/main/resources/db/changelog/changes/29_user_disabled_flag.sql
new file mode 100644
index 000000000..c3be5462a
--- /dev/null
+++ b/server/src/main/resources/db/changelog/changes/29_user_disabled_flag.sql
@@ -0,0 +1,4 @@
+--liquibase formatted sql
+
+--changeset thesis-management:29-add-user-disabled-flag
+ALTER TABLE users ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/server/src/main/resources/db/changelog/db.changelog-master.xml b/server/src/main/resources/db/changelog/db.changelog-master.xml
index 65554f8b0..21c2d9ad7 100644
--- a/server/src/main/resources/db/changelog/db.changelog-master.xml
+++ b/server/src/main/resources/db/changelog/db.changelog-master.xml
@@ -32,4 +32,5 @@
+
diff --git a/server/src/test/java/de/tum/cit/aet/thesis/service/DataRetentionServiceTest.java b/server/src/test/java/de/tum/cit/aet/thesis/service/DataRetentionServiceTest.java
index 3b04af5c4..c2b40791c 100644
--- a/server/src/test/java/de/tum/cit/aet/thesis/service/DataRetentionServiceTest.java
+++ b/server/src/test/java/de/tum/cit/aet/thesis/service/DataRetentionServiceTest.java
@@ -5,10 +5,13 @@
import de.tum.cit.aet.thesis.constants.ApplicationState;
import de.tum.cit.aet.thesis.controller.payload.CreateApplicationPayload;
+import de.tum.cit.aet.thesis.controller.payload.CreateThesisPayload;
import de.tum.cit.aet.thesis.controller.payload.ReplaceTopicPayload;
+import de.tum.cit.aet.thesis.entity.User;
import de.tum.cit.aet.thesis.mock.BaseIntegrationTest;
import de.tum.cit.aet.thesis.repository.ApplicationRepository;
import de.tum.cit.aet.thesis.repository.ApplicationReviewerRepository;
+import de.tum.cit.aet.thesis.repository.UserRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
@@ -43,6 +46,12 @@ static void configureDynamicProperties(DynamicPropertyRegistry registry) {
@Autowired
private ApplicationReviewerRepository applicationReviewerRepository;
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private AuthenticationService authenticationService;
+
@Autowired
private EntityManager entityManager;
@@ -196,4 +205,128 @@ void deleteExpiredRejectedApplicationsReturnsCount() throws Exception {
assertThat(deleted).isPositive();
assertThat(applicationRepository.findById(applicationId)).isEmpty();
}
+
+ // --- Inactive user disabling tests ---
+
+ private void backdateUserActivity(UUID userId, int daysAgo) {
+ Instant pastDate = Instant.now().minus(daysAgo, ChronoUnit.DAYS);
+ transactionTemplate.executeWithoutResult(status -> {
+ entityManager.createNativeQuery(
+ "UPDATE users SET last_login_at = :date, updated_at = :date, joined_at = :date WHERE user_id = :id")
+ .setParameter("date", pastDate)
+ .setParameter("id", userId)
+ .executeUpdate();
+ entityManager.clear();
+ });
+ }
+
+ @Test
+ void disablesStudentInactiveForMoreThanOneYear() throws Exception {
+ TestUser student = createRandomTestUser(List.of("student"));
+ backdateUserActivity(student.userId(), 400);
+
+ int disabled = dataRetentionService.disableInactiveUsers();
+
+ assertThat(disabled).isPositive();
+ User user = userRepository.findById(student.userId()).orElseThrow();
+ assertThat(user.isDisabled()).isTrue();
+ }
+
+ @Test
+ void doesNotDisableRecentlyActiveStudent() throws Exception {
+ TestUser student = createRandomTestUser(List.of("student"));
+ // Student was just created with a recent last_login_at, so should not be disabled
+
+ int disabled = dataRetentionService.disableInactiveUsers();
+
+ assertThat(disabled).isZero();
+ User user = userRepository.findById(student.userId()).orElseThrow();
+ assertThat(user.isDisabled()).isFalse();
+ }
+
+ @Test
+ void doesNotDisableStudentWithActiveThesis() throws Exception {
+ createTestEmailTemplate("THESIS_CREATED");
+
+ TestUser advisor = createRandomTestUser(List.of("supervisor", "advisor"));
+ UUID researchGroupId = createTestResearchGroup("Active Thesis RG", advisor.universityId());
+
+ TestUser student = createRandomTestUser(List.of("student"));
+
+ CreateThesisPayload thesisPayload = new CreateThesisPayload(
+ "Active Thesis Test",
+ "MASTER",
+ "ENGLISH",
+ List.of(student.userId()),
+ List.of(advisor.userId()),
+ List.of(advisor.userId()),
+ researchGroupId
+ );
+ mockMvc.perform(MockMvcRequestBuilders.post("/v2/theses")
+ .header("Authorization", createRandomAdminAuthentication())
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(thesisPayload)))
+ .andReturn();
+
+ backdateUserActivity(student.userId(), 400);
+
+ int disabled = dataRetentionService.disableInactiveUsers();
+
+ assertThat(disabled).isZero();
+ User user = userRepository.findById(student.userId()).orElseThrow();
+ assertThat(user.isDisabled()).isFalse();
+ }
+
+ @Test
+ void doesNotDisableStudentWithRecentApplication() throws Exception {
+ TestUser student = createRandomTestUser(List.of("student"));
+ String studentAuth = generateTestAuthenticationHeader(student.universityId(), List.of("student"));
+
+ createTestApplication(studentAuth, "Recent Application");
+
+ backdateUserActivity(student.userId(), 400);
+
+ int disabled = dataRetentionService.disableInactiveUsers();
+
+ assertThat(disabled).isZero();
+ User user = userRepository.findById(student.userId()).orElseThrow();
+ assertThat(user.isDisabled()).isFalse();
+ }
+
+ @Test
+ void doesNotDisableSupervisorOrAdmin() throws Exception {
+ TestUser supervisor = createRandomTestUser(List.of("supervisor", "advisor"));
+ TestUser admin = createRandomTestUser(List.of("admin"));
+
+ backdateUserActivity(supervisor.userId(), 400);
+ backdateUserActivity(admin.userId(), 400);
+
+ int disabled = dataRetentionService.disableInactiveUsers();
+
+ assertThat(disabled).isZero();
+ assertThat(userRepository.findById(supervisor.userId()).orElseThrow().isDisabled()).isFalse();
+ assertThat(userRepository.findById(admin.userId()).orElseThrow().isDisabled()).isFalse();
+ }
+
+ @Test
+ void reEnablesDisabledUserOnLogin() throws Exception {
+ TestUser student = createRandomTestUser(List.of("student"));
+
+ // Manually disable the user
+ transactionTemplate.executeWithoutResult(status -> {
+ entityManager.createNativeQuery("UPDATE users SET disabled = TRUE WHERE user_id = :id")
+ .setParameter("id", student.userId())
+ .executeUpdate();
+ entityManager.clear();
+ });
+
+ // Simulate login via updateAuthenticatedUser
+ String authHeader = generateTestAuthenticationHeader(student.universityId(), List.of("student"));
+ mockMvc.perform(MockMvcRequestBuilders.get("/v2/user-info")
+ .header("Authorization", authHeader))
+ .andReturn();
+
+ User user = userRepository.findById(student.userId()).orElseThrow();
+ assertThat(user.isDisabled()).isFalse();
+ }
}
diff --git a/server/src/test/resources/application.yml b/server/src/test/resources/application.yml
index b19a2699c..ec6cd1deb 100644
--- a/server/src/test/resources/application.yml
+++ b/server/src/test/resources/application.yml
@@ -60,6 +60,7 @@ thesis-management:
data-retention:
cron: "-"
rejected-application-retention-days: 365
+ inactive-user-days: 365
data-export:
path: data-exports
retention-days: 7
From ba8188a5a69978d244998b85a38f891ec11571b8 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 19:28:18 +0100
Subject: [PATCH 19/39] Add TODO comments to avoid @Transactional usage across
services
Co-Authored-By: Claude Opus 4.6
---
.../cit/aet/thesis/cron/AutomaticRejects.java | 1 +
.../service/AccessManagementService.java | 1 +
.../thesis/service/ApplicationService.java | 12 +
.../aet/thesis/service/DataExportService.java | 2 +
.../thesis/service/EmailTemplateService.java | 3 +
.../thesis/service/ResearchGroupService.java | 669 +++++++++---------
.../thesis/service/ThesisCommentService.java | 2 +
.../service/ThesisPresentationService.java | 5 +
.../cit/aet/thesis/service/ThesisService.java | 17 +
.../cit/aet/thesis/service/TopicService.java | 2 +
10 files changed, 377 insertions(+), 337 deletions(-)
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/cron/AutomaticRejects.java b/server/src/main/java/de/tum/cit/aet/thesis/cron/AutomaticRejects.java
index 493ef54ea..0270ef6e0 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/cron/AutomaticRejects.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/cron/AutomaticRejects.java
@@ -54,6 +54,7 @@ public AutomaticRejects(ResearchGroupSettingsRepository researchGroupSettingsRep
}
@Scheduled(cron = "0 00 09 * * *")
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public void rejectOldApplications() {
List enabledResearchGroups = researchGroupSettingsRepository.findAllByAutomaticRejectEnabled();
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/AccessManagementService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/AccessManagementService.java
index a47ab4929..da894ac34 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/AccessManagementService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/AccessManagementService.java
@@ -279,6 +279,7 @@ private void assignKeycloakRole(UUID userId, String role) {
.block();
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Set syncRolesFromKeycloakToDatabase(User user) {
if (user == null) {
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java
index b43c20cca..d846255fb 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/ApplicationService.java
@@ -171,6 +171,7 @@ public List getNotAssesedSuggestedOfResearchGroup(UUID researchGrou
return applicationRepository.findNotReviewedSuggestedByResearchGroup(researchGroupId);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Application createApplication(User user, UUID researchGroupId, UUID topicId, String thesisTitle,
String thesisType, Instant desiredStartDate, String motivation) {
@@ -204,6 +205,7 @@ public Application createApplication(User user, UUID researchGroupId, UUID topic
return application;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Application updateApplication(Application application, UUID topicId, String thesisTitle, String thesisType, Instant desiredStartDate, String motivation) {
currentUserProvider().assertCanAccessResearchGroup(application.getResearchGroup());
@@ -218,6 +220,7 @@ public Application updateApplication(Application application, UUID topicId, Stri
return application;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public List accept(
User reviewingUser,
@@ -271,6 +274,7 @@ public List accept(
return result;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public List reject(User reviewingUser, Application application,
ApplicationRejectReason reason, boolean notifyUser, boolean authenticated) {
@@ -315,6 +319,7 @@ public List reject(User reviewingUser, Application application,
return result;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public void rejectAllApplicationsAutomatically(Topic topic, int afterDuration, Instant referenceDate, UUID researchGroupId) {
List applications = applicationRepository.findAllByTopic(topic);
@@ -385,6 +390,7 @@ public List getListOfApplicationsThatWillBeRejected(Top
return result;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public void rejectListOfApplicationsIfOlderThan(
List applications, int afterDuration, UUID researchGroupId) {
@@ -403,6 +409,7 @@ public void rejectListOfApplicationsIfOlderThan(
}
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Topic closeTopic(Topic topic, ApplicationRejectReason reason, boolean notifyUser) {
currentUserProvider().assertCanAccessResearchGroup(topic.getResearchGroup());
@@ -419,6 +426,7 @@ public Topic closeTopic(Topic topic, ApplicationRejectReason reason, boolean not
return topicRepository.save(topic);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public List rejectApplicationsForTopic(User closer, Topic topic, ApplicationRejectReason reason, boolean notifyUser) {
currentUserProvider().assertCanAccessResearchGroup(topic.getResearchGroup());
@@ -436,6 +444,7 @@ public List rejectApplicationsForTopic(User closer, Topic topic, Ap
return result;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Application reviewApplication(Application application, User reviewer, ApplicationReviewReason reason) {
currentUserProvider().assertCanAccessResearchGroup(application.getResearchGroup());
@@ -443,6 +452,7 @@ public Application reviewApplication(Application application, User reviewer, App
return reviewApplicationWithoutAuth(application, reviewer, reason);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Application reviewApplicationWithoutAuth(Application application, User reviewer, ApplicationReviewReason reason) {
ApplicationReviewer entity = application.getReviewer(reviewer).orElseGet(() -> {
@@ -476,6 +486,7 @@ public Application reviewApplicationWithoutAuth(Application application, User re
return applicationRepository.save(application);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Application updateComment(Application application, String comment) {
currentUserProvider().assertCanAccessResearchGroup(application.getResearchGroup());
@@ -507,6 +518,7 @@ public Application findById(UUID applicationId) {
return application;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public void deleteApplication(UUID applicationId) {
findById(applicationId);
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/DataExportService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/DataExportService.java
index ebcc8fe7a..0cf21e021 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/DataExportService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/DataExportService.java
@@ -83,6 +83,7 @@ public DataExportService(
}
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public DataExport requestDataExport(User user) {
RequestStatus status = canRequestDataExport(user);
@@ -135,6 +136,7 @@ public DataExport findById(UUID id) {
.orElseThrow(() -> new ResourceNotFoundException("Data export not found"));
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Resource downloadDataExport(DataExport export, User user) {
if (!export.getUser().getId().equals(user.getId()) && !user.hasAnyGroup("admin")) {
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/EmailTemplateService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/EmailTemplateService.java
index 52717aa9b..d8e2d42db 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/EmailTemplateService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/EmailTemplateService.java
@@ -151,6 +151,7 @@ public EmailTemplate findById(UUID emailTemplateId) {
return emailTemplate;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public EmailTemplate createEmailTemplate(
UUID researchGroupId,
@@ -192,6 +193,7 @@ public EmailTemplate createEmailTemplate(
return emailTemplateRepository.save(emailTemplate);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public EmailTemplate updateEmailTemplate(
EmailTemplate emailTemplate,
@@ -219,6 +221,7 @@ public EmailTemplate updateEmailTemplate(
return emailTemplateRepository.save(emailTemplate);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public void deleteEmailTemplate(UUID emailTemplateId) {
EmailTemplate emailTemplate = emailTemplateRepository.findById(emailTemplateId)
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/ResearchGroupService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/ResearchGroupService.java
index 8a48593ae..65ce9bcba 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/ResearchGroupService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/ResearchGroupService.java
@@ -12,406 +12,401 @@
import de.tum.cit.aet.thesis.utility.HibernateHelper;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.PageImpl;
-import org.springframework.data.domain.PageRequest;
-import org.springframework.data.domain.Pageable;
-import org.springframework.data.domain.Sort;
+import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
-import java.util.ArrayList;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.UUID;
+import java.util.*;
-/** Manages research group lifecycle, membership, and role assignments with Keycloak synchronization. */
+/**
+ * Manages research group lifecycle, membership, and role assignments with Keycloak synchronization.
+ */
@Service
public class ResearchGroupService {
-private final ResearchGroupRepository researchGroupRepository;
-private final UserService userService;
-private final ObjectProvider currentUserProviderProvider;
-private final UserRepository userRepository;
-private final AccessManagementService accessManagementService;
-
-private final ThesisRepository thesisRepository;
-
-/**
- * Injects the research group repository, user service, access management, and current user provider.
- *
- * @param researchGroupRepository the research group repository
- * @param userService the user service
- * @param currentUserProviderProvider the current user provider
- * @param userRepository the user repository
- * @param accessManagementService the access management service
- * @param thesisRepository the thesis repository
- */
-@Autowired
-public ResearchGroupService(ResearchGroupRepository researchGroupRepository,
- UserService userService, ObjectProvider currentUserProviderProvider,
- UserRepository userRepository, AccessManagementService accessManagementService, ThesisRepository thesisRepository) {
- this.researchGroupRepository = researchGroupRepository;
- this.userService = userService;
- this.currentUserProviderProvider = currentUserProviderProvider;
- this.userRepository = userRepository;
- this.accessManagementService = accessManagementService;
- this.thesisRepository = thesisRepository;
-}
+ private final ResearchGroupRepository researchGroupRepository;
+ private final UserService userService;
+ private final ObjectProvider currentUserProviderProvider;
+ private final UserRepository userRepository;
+ private final AccessManagementService accessManagementService;
-private CurrentUserProvider currentUserProvider() {
- return currentUserProviderProvider.getObject();
-}
+ private final ThesisRepository thesisRepository;
-/**
- * Returns a paginated and filtered list of research groups visible to the current user.
- *
- * @param heads the head usernames to filter by
- * @param campuses the campuses to filter by
- * @param includeArchived whether to include archived research groups
- * @param searchQuery the search query to filter results
- * @param page the page number for pagination
- * @param limit the number of items per page
- * @param sortBy the field to sort by
- * @param sortOrder the sort direction (asc or desc)
- * @return the paginated list of research groups
- */
-public Page getAll(
- String[] heads,
- String[] campuses,
- boolean includeArchived,
- String searchQuery,
- int page,
- int limit,
- String sortBy,
- String sortOrder
-) {
- if (!currentUserProvider().canSeeAllResearchGroups()) {
- return new PageImpl<>(List.of(currentUserProvider().getResearchGroupOrThrow()),
- PageRequest.of(0, 1),
- 1);
+ /**
+ * Injects the research group repository, user service, access management, and current user provider.
+ *
+ * @param researchGroupRepository the research group repository
+ * @param userService the user service
+ * @param currentUserProviderProvider the current user provider
+ * @param userRepository the user repository
+ * @param accessManagementService the access management service
+ * @param thesisRepository the thesis repository
+ */
+ @Autowired
+ public ResearchGroupService(ResearchGroupRepository researchGroupRepository,
+ UserService userService, ObjectProvider currentUserProviderProvider,
+ UserRepository userRepository, AccessManagementService accessManagementService, ThesisRepository thesisRepository) {
+ this.researchGroupRepository = researchGroupRepository;
+ this.userService = userService;
+ this.currentUserProviderProvider = currentUserProviderProvider;
+ this.userRepository = userRepository;
+ this.accessManagementService = accessManagementService;
+ this.thesisRepository = thesisRepository;
}
- Sort.Order order = new Sort.Order(
- sortOrder.equals("asc") ? Sort.Direction.ASC : Sort.Direction.DESC,
- HibernateHelper.getColumnName(ResearchGroup.class, sortBy)
- );
-
- String searchQueryFilter =
- searchQuery == null || searchQuery.isEmpty() ? null : searchQuery.toLowerCase();
- String[] headsFilter = heads == null || heads.length == 0 ? null : heads;
- String[] campusesFilter = campuses == null || campuses.length == 0 ? null : campuses;
-
- Pageable pageable = limit == -1
- ? PageRequest.of(0, Integer.MAX_VALUE, Sort.by(order))
- : PageRequest.of(page, limit, Sort.by(order));
-
- return researchGroupRepository.searchResearchGroup(
- headsFilter,
- campusesFilter,
- includeArchived,
- searchQueryFilter,
- pageable
- );
-}
-
-/**
- * Returns all non-archived research groups matching the search query without access restrictions.
- *
- * @param searchQuery the search query to filter results
- * @return the page of matching research groups
- */
-public Page getAllLight(String searchQuery) {
- String searchQueryFilter =
- searchQuery == null || searchQuery.isEmpty() ? null : searchQuery.toLowerCase();
-
- Sort.Order order = new Sort.Order(Sort.Direction.ASC, HibernateHelper.getColumnName(ResearchGroup.class, "name"));
-
- return researchGroupRepository.searchResearchGroup(
- null,
- null,
- false,
- searchQueryFilter,
- PageRequest.of(0, Integer.MAX_VALUE, Sort.by(order))
- );
-}
-/**
- * Returns the active research groups accessible to the current user, including groups via active theses.
- *
- * @return the list of active research groups for the current user
- */
-public List getActiveResearchGroupsForUser() {
+ private CurrentUserProvider currentUserProvider() {
+ return currentUserProviderProvider.getObject();
+ }
- User currentUser = currentUserProviderProvider.getObject().getUser();
+ /**
+ * Returns a paginated and filtered list of research groups visible to the current user.
+ *
+ * @param heads the head usernames to filter by
+ * @param campuses the campuses to filter by
+ * @param includeArchived whether to include archived research groups
+ * @param searchQuery the search query to filter results
+ * @param page the page number for pagination
+ * @param limit the number of items per page
+ * @param sortBy the field to sort by
+ * @param sortOrder the sort direction (asc or desc)
+ * @return the paginated list of research groups
+ */
+ public Page getAll(
+ String[] heads,
+ String[] campuses,
+ boolean includeArchived,
+ String searchQuery,
+ int page,
+ int limit,
+ String sortBy,
+ String sortOrder
+ ) {
+ if (!currentUserProvider().canSeeAllResearchGroups()) {
+ return new PageImpl<>(List.of(currentUserProvider().getResearchGroupOrThrow()),
+ PageRequest.of(0, 1),
+ 1);
+ }
+ Sort.Order order = new Sort.Order(
+ sortOrder.equals("asc") ? Sort.Direction.ASC : Sort.Direction.DESC,
+ HibernateHelper.getColumnName(ResearchGroup.class, sortBy)
+ );
- if (currentUser == null) {
- throw new AccessDeniedException("User is not authenticated.");
+ String searchQueryFilter =
+ searchQuery == null || searchQuery.isEmpty() ? null : searchQuery.toLowerCase();
+ String[] headsFilter = heads == null || heads.length == 0 ? null : heads;
+ String[] campusesFilter = campuses == null || campuses.length == 0 ? null : campuses;
+
+ Pageable pageable = limit == -1
+ ? PageRequest.of(0, Integer.MAX_VALUE, Sort.by(order))
+ : PageRequest.of(page, limit, Sort.by(order));
+
+ return researchGroupRepository.searchResearchGroup(
+ headsFilter,
+ campusesFilter,
+ includeArchived,
+ searchQueryFilter,
+ pageable
+ );
}
- // Return all groups if the person is admin
- if (currentUser.hasAnyGroup("admin")) {
+ /**
+ * Returns all non-archived research groups matching the search query without access restrictions.
+ *
+ * @param searchQuery the search query to filter results
+ * @return the page of matching research groups
+ */
+ public Page getAllLight(String searchQuery) {
+ String searchQueryFilter =
+ searchQuery == null || searchQuery.isEmpty() ? null : searchQuery.toLowerCase();
+
Sort.Order order = new Sort.Order(Sort.Direction.ASC, HibernateHelper.getColumnName(ResearchGroup.class, "name"));
- Page allResearchGroups = researchGroupRepository.searchResearchGroup(
+ return researchGroupRepository.searchResearchGroup(
null,
null,
false,
- "",
+ searchQueryFilter,
PageRequest.of(0, Integer.MAX_VALUE, Sort.by(order))
);
-
- return allResearchGroups.stream().toList();
}
- Set result = new HashSet<>();
- if (currentUser.getResearchGroup() != null) {
- result.add(currentUser.getResearchGroup());
- }
+ /**
+ * Returns the active research groups accessible to the current user, including groups via active theses.
+ *
+ * @return the list of active research groups for the current user
+ */
+ public List getActiveResearchGroupsForUser() {
- List viaTheses = thesisRepository.findActiveStudentThesisResearchGroups(currentUser.getId());
- result.addAll(viaTheses);
+ User currentUser = currentUserProviderProvider.getObject().getUser();
- return new ArrayList<>(result);
-}
+ if (currentUser == null) {
+ throw new AccessDeniedException("User is not authenticated.");
+ }
-/**
- * Finds a research group by its ID with access control enforcement.
- *
- * @param researchGroupId the unique identifier of the research group
- * @return the found research group
- */
-public ResearchGroup findById(UUID researchGroupId) {
- return findById(researchGroupId, false);
-}
+ // Return all groups if the person is admin
+ if (currentUser.hasAnyGroup("admin")) {
+ Sort.Order order = new Sort.Order(Sort.Direction.ASC, HibernateHelper.getColumnName(ResearchGroup.class, "name"));
-/**
- * Finds a research group by its abbreviation.
- *
- * @param abbreviation the abbreviation of the research group
- * @return the found research group
- */
-public ResearchGroup findByAbbreviation(String abbreviation) {
- return researchGroupRepository.findByAbbreviation(abbreviation);
-}
+ Page allResearchGroups = researchGroupRepository.searchResearchGroup(
+ null,
+ null,
+ false,
+ "",
+ PageRequest.of(0, Integer.MAX_VALUE, Sort.by(order))
+ );
-/**
- * Finds a research group by its ID, optionally bypassing access control checks.
- *
- * @param researchGroupId the unique identifier of the research group
- * @param noAuthentication whether to skip access control checks
- * @return the found research group
- */
-public ResearchGroup findById(UUID researchGroupId, boolean noAuthentication) {
- ResearchGroup researchGroup = researchGroupRepository.findById(researchGroupId)
- .orElseThrow(() -> new ResourceNotFoundException(
- String.format("Research Group with id %s not found.", researchGroupId)));
- if (!noAuthentication) {
- currentUserProvider().assertCanAccessResearchGroup(researchGroup);
+ return allResearchGroups.stream().toList();
+ }
+
+ Set result = new HashSet<>();
+ if (currentUser.getResearchGroup() != null) {
+ result.add(currentUser.getResearchGroup());
+ }
+
+ List viaTheses = thesisRepository.findActiveStudentThesisResearchGroups(currentUser.getId());
+ result.addAll(viaTheses);
+
+ return new ArrayList<>(result);
}
- return researchGroup;
-}
-@Transactional
-public ResearchGroup createResearchGroup(
- String headUsername,
- String name,
- String abbreviation,
- String description,
- String websiteUrl,
- String campus
-) {
- //Get the User by universityId else create the user
- User head = getUserByUsernameOrCreate(headUsername);
- if (head.getResearchGroup() != null) {
- throw new AccessDeniedException("User is already assigned to a research group.");
+ /**
+ * Finds a research group by its ID with access control enforcement.
+ *
+ * @param researchGroupId the unique identifier of the research group
+ * @return the found research group
+ */
+ public ResearchGroup findById(UUID researchGroupId) {
+ return findById(researchGroupId, false);
}
- //Add supervisor role in keycloak
- accessManagementService.assignSupervisorRole(head);
- accessManagementService.assignGroupAdminRole(head);
- Set updatedGroupsHead = accessManagementService.syncRolesFromKeycloakToDatabase(head);
- head.setGroups(updatedGroupsHead);
-
- ResearchGroup researchGroup = new ResearchGroup();
- researchGroup.setHead(head);
- researchGroup.setName(name);
- researchGroup.setAbbreviation(abbreviation);
- researchGroup.setDescription(description);
- researchGroup.setWebsiteUrl(websiteUrl);
- researchGroup.setCampus(campus);
- researchGroup.setCreatedAt(Instant.now());
- researchGroup.setUpdatedAt(Instant.now());
- researchGroup.setCreatedBy(currentUserProvider().getUser());
- researchGroup.setUpdatedBy(currentUserProvider().getUser());
- researchGroup.setArchived(false);
-
- ResearchGroup savedResearchGroup = researchGroupRepository.save(researchGroup);
-
- head.setResearchGroup(savedResearchGroup);
- userRepository.save(head);
-
- return savedResearchGroup;
-}
+ /**
+ * Finds a research group by its abbreviation.
+ *
+ * @param abbreviation the abbreviation of the research group
+ * @return the found research group
+ */
+ public ResearchGroup findByAbbreviation(String abbreviation) {
+ return researchGroupRepository.findByAbbreviation(abbreviation);
+ }
-private User getUserByUsernameOrCreate(String username) {
- User user = userRepository.findByUniversityId(username).orElseGet(() -> {
- User newUser = new User();
- Instant currentTime = Instant.now();
+ /**
+ * Finds a research group by its ID, optionally bypassing access control checks.
+ *
+ * @param researchGroupId the unique identifier of the research group
+ * @param noAuthentication whether to skip access control checks
+ * @return the found research group
+ */
+ public ResearchGroup findById(UUID researchGroupId, boolean noAuthentication) {
+ ResearchGroup researchGroup = researchGroupRepository.findById(researchGroupId)
+ .orElseThrow(() -> new ResourceNotFoundException(
+ String.format("Research Group with id %s not found.", researchGroupId)));
+ if (!noAuthentication) {
+ currentUserProvider().assertCanAccessResearchGroup(researchGroup);
+ }
+ return researchGroup;
+ }
- newUser.setJoinedAt(currentTime);
- newUser.setUpdatedAt(currentTime);
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
+ @Transactional
+ public ResearchGroup createResearchGroup(
+ String headUsername,
+ String name,
+ String abbreviation,
+ String description,
+ String websiteUrl,
+ String campus
+ ) {
+ //Get the User by universityId else create the user
+ User head = getUserByUsernameOrCreate(headUsername);
+ if (head.getResearchGroup() != null) {
+ throw new AccessDeniedException("User is already assigned to a research group.");
+ }
- // Load user data from Keycloak
- AccessManagementService.KeycloakUserInformation userElement = accessManagementService.getUserByUsername(username);
+ //Add supervisor role in keycloak
+ accessManagementService.assignSupervisorRole(head);
+ accessManagementService.assignGroupAdminRole(head);
+ Set updatedGroupsHead = accessManagementService.syncRolesFromKeycloakToDatabase(head);
+ head.setGroups(updatedGroupsHead);
- newUser.setUniversityId(userElement.username());
- newUser.setFirstName(userElement.firstName());
- newUser.setLastName(userElement.lastName());
- newUser.setEmail(userElement.email());
- newUser.setMatriculationNumber(userElement.getMatriculationNumber());
+ ResearchGroup researchGroup = new ResearchGroup();
+ researchGroup.setHead(head);
+ researchGroup.setName(name);
+ researchGroup.setAbbreviation(abbreviation);
+ researchGroup.setDescription(description);
+ researchGroup.setWebsiteUrl(websiteUrl);
+ researchGroup.setCampus(campus);
+ researchGroup.setCreatedAt(Instant.now());
+ researchGroup.setUpdatedAt(Instant.now());
+ researchGroup.setCreatedBy(currentUserProvider().getUser());
+ researchGroup.setUpdatedBy(currentUserProvider().getUser());
+ researchGroup.setArchived(false);
+
+ ResearchGroup savedResearchGroup = researchGroupRepository.save(researchGroup);
+
+ head.setResearchGroup(savedResearchGroup);
+ userRepository.save(head);
+
+ return savedResearchGroup;
+ }
- userRepository.save(newUser);
- return newUser;
- });
+ private User getUserByUsernameOrCreate(String username) {
+ User user = userRepository.findByUniversityId(username).orElseGet(() -> {
+ User newUser = new User();
+ Instant currentTime = Instant.now();
- return user;
-}
+ newUser.setJoinedAt(currentTime);
+ newUser.setUpdatedAt(currentTime);
-@Transactional
-public ResearchGroup updateResearchGroup(
- ResearchGroup researchGroup,
- String headUsername,
- String name,
- String abbreviation,
- String description,
- String websiteUrl,
- String campus
-) {
- if (researchGroup.isArchived()) {
- throw new AccessDeniedException("Cannot update an archived research group.");
- }
- //If user has group-admin rights he still needs to be part of the specific research group
- currentUserProvider().assertCanAccessResearchGroup(researchGroup);
+ // Load user data from Keycloak
+ AccessManagementService.KeycloakUserInformation userElement = accessManagementService.getUserByUsername(username);
- User oldHead = researchGroup.getHead();
- //Get the User by universityId else create the user
- User head = getUserByUsernameOrCreate(headUsername);
+ newUser.setUniversityId(userElement.username());
+ newUser.setFirstName(userElement.firstName());
+ newUser.setLastName(userElement.lastName());
+ newUser.setEmail(userElement.email());
+ newUser.setMatriculationNumber(userElement.getMatriculationNumber());
- //Update head only on change
- if (oldHead != head) {
- if (head.getResearchGroup() != null) {
- throw new AccessDeniedException("User is already assigned to a research group.");
- }
+ userRepository.save(newUser);
+ return newUser;
+ });
- //Remove ResearchGroup from old head and set it to the new head
- oldHead.setResearchGroup(null);
- researchGroup.setHead(head);
+ return user;
+ }
- //Give new head supervisor as role and remove the role from the old head
- try {
- accessManagementService.assignSupervisorRole(head);
- accessManagementService.assignGroupAdminRole(head);
- accessManagementService.removeResearchGroupRoles(oldHead);
- Set updatedGroupsHead = accessManagementService.syncRolesFromKeycloakToDatabase(head);
- head.setGroups(updatedGroupsHead);
- Set updatedGroupsOldHead = accessManagementService.syncRolesFromKeycloakToDatabase(oldHead);
- oldHead.setGroups(updatedGroupsOldHead);
- } catch (Exception e) {
- throw new AccessDeniedException("There was an error changing the head of the group, please try again.");
+ @Transactional
+ public ResearchGroup updateResearchGroup(
+ ResearchGroup researchGroup,
+ String headUsername,
+ String name,
+ String abbreviation,
+ String description,
+ String websiteUrl,
+ String campus
+ ) {
+ if (researchGroup.isArchived()) {
+ throw new AccessDeniedException("Cannot update an archived research group.");
}
- }
+ //If user has group-admin rights he still needs to be part of the specific research group
+ currentUserProvider().assertCanAccessResearchGroup(researchGroup);
- researchGroup.setName(name);
- researchGroup.setAbbreviation(abbreviation);
- researchGroup.setDescription(description);
- researchGroup.setWebsiteUrl(websiteUrl);
- researchGroup.setCampus(campus);
- researchGroup.setUpdatedAt(Instant.now());
- researchGroup.setUpdatedBy(currentUserProvider().getUser());
+ User oldHead = researchGroup.getHead();
+ //Get the User by universityId else create the user
+ User head = getUserByUsernameOrCreate(headUsername);
+
+ //Update head only on change
+ if (oldHead != head) {
+ if (head.getResearchGroup() != null) {
+ throw new AccessDeniedException("User is already assigned to a research group.");
+ }
+
+ //Remove ResearchGroup from old head and set it to the new head
+ oldHead.setResearchGroup(null);
+ researchGroup.setHead(head);
+
+ //Give new head supervisor as role and remove the role from the old head
+ try {
+ accessManagementService.assignSupervisorRole(head);
+ accessManagementService.assignGroupAdminRole(head);
+ accessManagementService.removeResearchGroupRoles(oldHead);
+ Set updatedGroupsHead = accessManagementService.syncRolesFromKeycloakToDatabase(head);
+ head.setGroups(updatedGroupsHead);
+ Set updatedGroupsOldHead = accessManagementService.syncRolesFromKeycloakToDatabase(oldHead);
+ oldHead.setGroups(updatedGroupsOldHead);
+ } catch (Exception e) {
+ throw new AccessDeniedException("There was an error changing the head of the group, please try again.");
+ }
+ }
- ResearchGroup savedResearchGroup = researchGroupRepository.save(researchGroup);
+ researchGroup.setName(name);
+ researchGroup.setAbbreviation(abbreviation);
+ researchGroup.setDescription(description);
+ researchGroup.setWebsiteUrl(websiteUrl);
+ researchGroup.setCampus(campus);
+ researchGroup.setUpdatedAt(Instant.now());
+ researchGroup.setUpdatedBy(currentUserProvider().getUser());
- head.setResearchGroup(savedResearchGroup);
- userRepository.save(oldHead);
- userRepository.save(head);
+ ResearchGroup savedResearchGroup = researchGroupRepository.save(researchGroup);
- return savedResearchGroup;
-}
+ head.setResearchGroup(savedResearchGroup);
+ userRepository.save(oldHead);
+ userRepository.save(head);
-/**
- * Returns a paginated list of members belonging to the specified research group.
- *
- * @param researchGroupId the unique identifier of the research group
- * @param page the page number for pagination
- * @param limit the number of items per page
- * @param sortBy the field to sort by
- * @param sortOrder the sort direction (asc or desc)
- * @return the paginated list of research group members
- */
-public Page getAllResearchGroupMembers(UUID researchGroupId, Integer page, Integer limit, String sortBy, String sortOrder) {
- ResearchGroup researchGroup = findById(researchGroupId);
- currentUserProvider().assertCanAccessResearchGroup(researchGroup);
+ return savedResearchGroup;
+ }
- Sort.Order order = new Sort.Order(sortOrder.equals("asc") ? Sort.Direction.ASC : Sort.Direction.DESC, sortBy);
+ /**
+ * Returns a paginated list of members belonging to the specified research group.
+ *
+ * @param researchGroupId the unique identifier of the research group
+ * @param page the page number for pagination
+ * @param limit the number of items per page
+ * @param sortBy the field to sort by
+ * @param sortOrder the sort direction (asc or desc)
+ * @return the paginated list of research group members
+ */
+ public Page getAllResearchGroupMembers(UUID researchGroupId, Integer page, Integer limit, String sortBy, String sortOrder) {
+ ResearchGroup researchGroup = findById(researchGroupId);
+ currentUserProvider().assertCanAccessResearchGroup(researchGroup);
- return userRepository
- .searchUsers(researchGroupId, null, null, PageRequest.of(page, limit, Sort.by(order)));
-}
+ Sort.Order order = new Sort.Order(sortOrder.equals("asc") ? Sort.Direction.ASC : Sort.Direction.DESC, sortBy);
-/**
- * Archives the given research group, preventing further modifications to it.
- *
- * @param researchGroup the research group to archive
- */
-public void archiveResearchGroup(ResearchGroup researchGroup) {
- currentUserProvider().assertCanAccessResearchGroup(researchGroup);
- researchGroup.setUpdatedAt(Instant.now());
- researchGroup.setUpdatedBy(currentUserProvider().getUser());
- researchGroup.setArchived(true);
+ return userRepository
+ .searchUsers(researchGroupId, null, null, PageRequest.of(page, limit, Sort.by(order)));
+ }
- researchGroupRepository.save(researchGroup);
-}
+ /**
+ * Archives the given research group, preventing further modifications to it.
+ *
+ * @param researchGroup the research group to archive
+ */
+ public void archiveResearchGroup(ResearchGroup researchGroup) {
+ currentUserProvider().assertCanAccessResearchGroup(researchGroup);
+ researchGroup.setUpdatedAt(Instant.now());
+ researchGroup.setUpdatedBy(currentUserProvider().getUser());
+ researchGroup.setArchived(true);
-/**
- * Assigns a user to a research group and grants them the advisor role in Keycloak.
- *
- * @param username the username of the user to assign
- * @param researchGroupId the unique identifier of the research group
- * @return the assigned user
- */
-public User assignUserToResearchGroup(String username, UUID researchGroupId) {
+ researchGroupRepository.save(researchGroup);
+ }
- ResearchGroup researchGroup = findById(researchGroupId);
- //If user has group-admin rights he still needs to be part of the specific research group
- currentUserProvider().assertCanAccessResearchGroup(researchGroup);
+ /**
+ * Assigns a user to a research group and grants them the advisor role in Keycloak.
+ *
+ * @param username the username of the user to assign
+ * @param researchGroupId the unique identifier of the research group
+ * @return the assigned user
+ */
+ public User assignUserToResearchGroup(String username, UUID researchGroupId) {
- User user = getUserByUsernameOrCreate(username);
+ ResearchGroup researchGroup = findById(researchGroupId);
+ //If user has group-admin rights he still needs to be part of the specific research group
+ currentUserProvider().assertCanAccessResearchGroup(researchGroup);
- if (user.getResearchGroup() != null) {
- throw new AccessDeniedException("User is already assigned to a research group.");
- }
+ User user = getUserByUsernameOrCreate(username);
- if (researchGroup != null && researchGroup.isArchived()) {
- throw new AccessDeniedException("Cannot assign user to an archived research group.");
- }
+ if (user.getResearchGroup() != null) {
+ throw new AccessDeniedException("User is already assigned to a research group.");
+ }
- user.setResearchGroup(researchGroup);
+ if (researchGroup != null && researchGroup.isArchived()) {
+ throw new AccessDeniedException("Cannot assign user to an archived research group.");
+ }
- //Assign member the advisor role in keycloak and update database
- accessManagementService.assignAdvisorRole(user);
- Set updatedGroups = accessManagementService.syncRolesFromKeycloakToDatabase(user);
- user.setGroups(updatedGroups);
+ user.setResearchGroup(researchGroup);
- userRepository.save(user);
- return user;
-}
+ //Assign member the advisor role in keycloak and update database
+ accessManagementService.assignAdvisorRole(user);
+ Set updatedGroups = accessManagementService.syncRolesFromKeycloakToDatabase(user);
+ user.setGroups(updatedGroups);
+
+ userRepository.save(user);
+ return user;
+ }
/**
* Removes a user from a research group and revokes their research group roles in Keycloak.
*
- * @param userId the unique identifier of the user to remove
+ * @param userId the unique identifier of the user to remove
* @param researchGroupId the unique identifier of the research group
* @return the removed user
*/
@@ -422,7 +417,7 @@ public User removeUserFromResearchGroup(UUID userId, UUID researchGroupId) {
currentUserProvider().assertCanAccessResearchGroup(researchGroup);
if (!user.getResearchGroup().getId().equals(researchGroupId)) {
- throw new AccessDeniedException("User is not assigned to this research group.");
+ throw new AccessDeniedException("User is not assigned to this research group.");
}
if (user.getResearchGroup().isArchived()) {
throw new AccessDeniedException("Cannot remove user from an archived research group.");
@@ -451,8 +446,8 @@ public User removeUserFromResearchGroup(UUID userId, UUID researchGroupId) {
* Updates the Keycloak role of a research group member to the specified advisor or supervisor role.
*
* @param researchGroupId the unique identifier of the research group
- * @param userId the unique identifier of the member
- * @param role the new role to assign (advisor or supervisor)
+ * @param userId the unique identifier of the member
+ * @param role the new role to assign (advisor or supervisor)
* @return the updated user
*/
public User updateResearchGroupMemberRole(
@@ -492,7 +487,7 @@ public User updateResearchGroupMemberRole(
* Toggles the group-admin Keycloak role for the specified user in the research group.
*
* @param researchGroupId the unique identifier of the research group
- * @param userId the unique identifier of the user
+ * @param userId the unique identifier of the user
* @return the updated user
*/
public User changeResearchGroupAdminRole(UUID researchGroupId, UUID userId) {
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisCommentService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisCommentService.java
index 691bb9136..f1b47b36c 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisCommentService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisCommentService.java
@@ -64,6 +64,7 @@ public Page getComments(Thesis thesis, ThesisCommentType commentT
);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public ThesisComment postComment(Thesis thesis, ThesisCommentType commentType, String message, MultipartFile file) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
@@ -98,6 +99,7 @@ public Resource getCommentFile(ThesisComment comment) {
return uploadService.load(comment.getFilename());
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public ThesisComment deleteComment(ThesisComment comment) {
currentUserProvider().assertCanAccessResearchGroup(comment.getResearchGroup());
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisPresentationService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisPresentationService.java
index 1abb1d9cd..3d30e2456 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisPresentationService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisPresentationService.java
@@ -190,6 +190,7 @@ public Calendar getPresentationInvite(ThesisPresentation presentation) {
return calendar;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis createPresentation(
Thesis thesis,
@@ -224,6 +225,7 @@ public Thesis createPresentation(
return thesisRepository.save(thesis);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis updatePresentation(
ThesisPresentation presentation,
@@ -254,6 +256,7 @@ public Thesis updatePresentation(
return thesis;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis updatePresentationNote(ThesisPresentation presentation, String note) {
Thesis thesis = presentation.getThesis();
@@ -269,6 +272,7 @@ public Thesis updatePresentationNote(ThesisPresentation presentation, String not
return thesis;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis schedulePresentation(
ThesisPresentation presentation,
@@ -341,6 +345,7 @@ public Thesis schedulePresentation(
return thesis;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis deletePresentation(ThesisPresentation presentation) {
Thesis thesis = presentation.getThesis();
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java
index 69c0f29c4..06af79d88 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/ThesisService.java
@@ -193,6 +193,7 @@ public Page getAll(
);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis createThesis(
String thesisTitle,
@@ -242,6 +243,7 @@ public Thesis createThesis(
return thesis;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis closeThesis(Thesis thesis) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
@@ -265,6 +267,7 @@ public Thesis closeThesis(Thesis thesis) {
return thesis;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis updateThesis(
Thesis thesis,
@@ -309,6 +312,7 @@ public Thesis updateThesis(
return thesis;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis updateThesisInfo(
Thesis thesis,
@@ -324,6 +328,7 @@ public Thesis updateThesisInfo(
return thesis;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis updateThesisTitles(
Thesis thesis,
@@ -346,6 +351,7 @@ public Thesis updateThesisTitles(
return thesis;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis updateThesisCredits(
Thesis thesis,
@@ -381,6 +387,7 @@ public Thesis completeFeedback(Thesis thesis, UUID feedbackId, boolean completed
return thesis;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis deleteFeedback(Thesis thesis, UUID feedbackId) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
@@ -396,6 +403,7 @@ public Thesis deleteFeedback(Thesis thesis, UUID feedbackId) {
return thesis;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis requestChanges(Thesis thesis, ThesisFeedbackType type, List requestedChanges) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
@@ -435,6 +443,7 @@ public Resource getProposalFile(ThesisProposal proposal) {
return uploadService.load(proposal.getProposalFilename());
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis uploadProposal(Thesis thesis, MultipartFile proposalFile) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
@@ -457,6 +466,7 @@ public Thesis uploadProposal(Thesis thesis, MultipartFile proposalFile) {
return thesisRepository.save(thesis);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis deleteProposal(Thesis thesis, UUID proposalId) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
@@ -472,6 +482,7 @@ public Thesis deleteProposal(Thesis thesis, UUID proposalId) {
return thesis;
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis acceptProposal(Thesis thesis) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
@@ -499,6 +510,7 @@ public Thesis acceptProposal(Thesis thesis) {
/* WRITING */
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis submitThesis(Thesis thesis) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
@@ -515,6 +527,7 @@ public Thesis submitThesis(Thesis thesis) {
return thesisRepository.save(thesis);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis uploadThesisFile(Thesis thesis, String type, MultipartFile file) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
@@ -533,6 +546,7 @@ public Thesis uploadThesisFile(Thesis thesis, String type, MultipartFile file) {
return thesisRepository.save(thesis);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis deleteThesisFile(Thesis thesis, UUID fileId) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
@@ -562,6 +576,7 @@ public Resource getThesisFile(ThesisFile file) {
}
/* ASSESSMENT */
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis submitAssessment(
Thesis thesis,
@@ -643,6 +658,7 @@ public Resource getAssessmentFile(Thesis thesis) {
}
/* GRADING */
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis gradeThesis(Thesis thesis, String finalGrade, String finalFeedback, ThesisVisibility visibility) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
@@ -658,6 +674,7 @@ public Thesis gradeThesis(Thesis thesis, String finalGrade, String finalFeedback
return thesisRepository.save(thesis);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Thesis completeThesis(Thesis thesis) {
currentUserProvider().assertCanAccessResearchGroup(thesis.getResearchGroup());
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/TopicService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/TopicService.java
index 7729ea247..d37518b0f 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/TopicService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/TopicService.java
@@ -178,6 +178,7 @@ public List getOpenFromResearchGroup(UUID researchGroupId) {
).toList();
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Topic createTopic(
String title,
@@ -222,6 +223,7 @@ public Topic createTopic(
return topicRepository.save(topic);
}
+ // TODO: we should avoid using @Transactional because it can lead to performance issue and concurrency problems
@Transactional
public Topic updateTopic(
Topic topic,
From 9d21f12c026da471de96fca1badef5fec40ecda4 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 19:52:07 +0100
Subject: [PATCH 20/39] Implement GDPR Art. 17 account/data deletion feature
Add self-service and admin account deletion with two scenarios:
- Full deletion when no retention-blocked data exists
- Soft-deletion (deactivate + schedule) when thesis data is under
5-year legal retention, preserving profile and thesis-related files
so professors can still find theses by student name
Includes: Liquibase migration for deletion tracking columns and FK
constraint changes, UserDeletionService with preview/delete logic,
REST endpoints, auth guard for deleted accounts, nightly deferred
deletion job, Settings page Account tab, and Admin page deletion UI.
Co-Authored-By: Claude Opus 4.6
---
client/src/pages/AdminPage/AdminPage.tsx | 191 +++++++++++-
.../src/pages/SettingsPage/SettingsPage.tsx | 7 +-
.../AccountDeletion/AccountDeletion.tsx | 148 ++++++++++
docs/DATA_RETENTION.md | 1 +
.../controller/UserDeletionController.java | 57 ++++
.../thesis/dto/UserDeletionPreviewDto.java | 16 +
.../aet/thesis/dto/UserDeletionResultDto.java | 7 +
.../de/tum/cit/aet/thesis/entity/User.java | 13 +
.../repository/ApplicationRepository.java | 7 +
.../repository/ResearchGroupRepository.java | 4 +-
.../repository/ThesisRoleRepository.java | 4 +
.../repository/TopicRoleRepository.java | 4 +
.../aet/thesis/repository/UserRepository.java | 2 +
.../thesis/service/AuthenticationService.java | 5 +
.../thesis/service/DataRetentionService.java | 4 +
.../cit/aet/thesis/service/UploadService.java | 11 +
.../thesis/service/UserDeletionService.java | 275 ++++++++++++++++++
.../db/changelog/changes/30_user_deletion.sql | 85 ++++++
.../db/changelog/db.changelog-master.xml | 1 +
19 files changed, 839 insertions(+), 3 deletions(-)
create mode 100644 client/src/pages/SettingsPage/components/AccountDeletion/AccountDeletion.tsx
create mode 100644 server/src/main/java/de/tum/cit/aet/thesis/controller/UserDeletionController.java
create mode 100644 server/src/main/java/de/tum/cit/aet/thesis/dto/UserDeletionPreviewDto.java
create mode 100644 server/src/main/java/de/tum/cit/aet/thesis/dto/UserDeletionResultDto.java
create mode 100644 server/src/main/java/de/tum/cit/aet/thesis/service/UserDeletionService.java
create mode 100644 server/src/main/resources/db/changelog/changes/30_user_deletion.sql
diff --git a/client/src/pages/AdminPage/AdminPage.tsx b/client/src/pages/AdminPage/AdminPage.tsx
index 594721a07..5d91fda0b 100644
--- a/client/src/pages/AdminPage/AdminPage.tsx
+++ b/client/src/pages/AdminPage/AdminPage.tsx
@@ -1,15 +1,54 @@
import React, { useState } from 'react'
-import { Button, Card, Group, Stack, Text, Title } from '@mantine/core'
+import {
+ Alert,
+ Button,
+ Card,
+ Group,
+ Loader,
+ Modal,
+ Stack,
+ Text,
+ TextInput,
+ Title,
+} from '@mantine/core'
+import { Warning } from '@phosphor-icons/react'
import { doRequest } from '../../requests/request'
import { showSimpleError, showSimpleSuccess } from '../../utils/notification'
import { getApiResponseErrorMessage } from '../../requests/handler'
+import { IUser } from '../../requests/responses/user'
interface IDataRetentionResult {
deletedApplications: number
}
+interface IDeletionPreview {
+ canBeFullyDeleted: boolean
+ hasActiveTheses: boolean
+ retentionBlockedThesisCount: number
+ earliestFullDeletionDate?: string
+ isResearchGroupHead: boolean
+ message: string
+}
+
+interface IDeletionResult {
+ result: string
+ message: string
+}
+
+interface IPageResponse {
+ content: T[]
+}
+
const AdminPage = () => {
const [loading, setLoading] = useState(false)
+ const [searchQuery, setSearchQuery] = useState('')
+ const [searchResults, setSearchResults] = useState([])
+ const [searching, setSearching] = useState(false)
+ const [selectedUser, setSelectedUser] = useState(null)
+ const [deletionPreview, setDeletionPreview] = useState(null)
+ const [previewLoading, setPreviewLoading] = useState(false)
+ const [deleting, setDeleting] = useState(false)
+ const [confirmOpen, setConfirmOpen] = useState(false)
const onRunCleanup = async () => {
setLoading(true)
@@ -39,6 +78,73 @@ const AdminPage = () => {
}
}
+ const onSearchUsers = async () => {
+ if (!searchQuery.trim()) return
+ setSearching(true)
+ setSelectedUser(null)
+ setDeletionPreview(null)
+ try {
+ const response = await doRequest>('/v2/users', {
+ method: 'GET',
+ requiresAuth: true,
+ params: { searchQuery: searchQuery.trim(), page: 0, limit: 10 },
+ })
+ if (response.ok) {
+ setSearchResults(response.data.content ?? [])
+ } else {
+ showSimpleError(getApiResponseErrorMessage(response))
+ }
+ } finally {
+ setSearching(false)
+ }
+ }
+
+ const onSelectUser = async (user: IUser) => {
+ setSelectedUser(user)
+ setPreviewLoading(true)
+ try {
+ const response = await doRequest(
+ `/v2/user-deletion/${user.userId}/preview`,
+ {
+ method: 'GET',
+ requiresAuth: true,
+ },
+ )
+ if (response.ok) {
+ setDeletionPreview(response.data)
+ } else {
+ showSimpleError(getApiResponseErrorMessage(response))
+ }
+ } finally {
+ setPreviewLoading(false)
+ }
+ }
+
+ const onDeleteUser = async () => {
+ if (!selectedUser) return
+ setConfirmOpen(false)
+ setDeleting(true)
+ try {
+ const response = await doRequest(
+ `/v2/user-deletion/${selectedUser.userId}`,
+ {
+ method: 'DELETE',
+ requiresAuth: true,
+ },
+ )
+ if (response.ok) {
+ showSimpleSuccess(response.data.message)
+ setSelectedUser(null)
+ setDeletionPreview(null)
+ setSearchResults([])
+ } else {
+ showSimpleError(getApiResponseErrorMessage(response))
+ }
+ } finally {
+ setDeleting(false)
+ }
+ }
+
return (
Administration
@@ -56,6 +162,89 @@ const AdminPage = () => {
+
+
+ User Account Deletion
+ Search for a user to preview and perform account deletion.
+
+ setSearchQuery(e.currentTarget.value)}
+ onKeyDown={(e) => e.key === 'Enter' && onSearchUsers()}
+ style={{ flex: 1 }}
+ />
+
+ Search
+
+
+ {searchResults.length > 0 && (
+
+ {searchResults.map((user) => (
+ onSelectUser(user)}
+ justify='flex-start'
+ >
+ {user.firstName} {user.lastName} ({user.universityId})
+
+ ))}
+
+ )}
+ {previewLoading && }
+ {selectedUser && deletionPreview && !previewLoading && (
+
+
+ Deletion preview for: {selectedUser.firstName} {selectedUser.lastName}
+
+ {deletionPreview.message}
+ {deletionPreview.isResearchGroupHead && (
+ }>
+ This user is a research group head.
+
+ )}
+ {deletionPreview.hasActiveTheses && (
+ }>
+ This user has active theses.
+
+ )}
+
+ setConfirmOpen(true)}
+ >
+ Delete User
+
+
+
+ )}
+
+
+
+ setConfirmOpen(false)}
+ title='Confirm User Deletion'
+ >
+
+ }>
+ This will {deletionPreview?.canBeFullyDeleted ? 'permanently delete' : 'deactivate'} the
+ account of {selectedUser?.firstName} {selectedUser?.lastName}. This action cannot be
+ undone.
+
+
+ setConfirmOpen(false)}>
+ Cancel
+
+
+ Confirm Deletion
+
+
+
+
)
}
diff --git a/client/src/pages/SettingsPage/SettingsPage.tsx b/client/src/pages/SettingsPage/SettingsPage.tsx
index 63cc78258..7d8eed614 100644
--- a/client/src/pages/SettingsPage/SettingsPage.tsx
+++ b/client/src/pages/SettingsPage/SettingsPage.tsx
@@ -1,8 +1,9 @@
import React from 'react'
import { Space, Tabs } from '@mantine/core'
-import { EnvelopeOpen, User } from '@phosphor-icons/react'
+import { EnvelopeOpen, User, UserMinus } from '@phosphor-icons/react'
import MyInformation from './components/MyInformation/MyInformation'
import NotificationSettings from './components/NotificationSettings/NotificationSettings'
+import AccountDeletion from './components/AccountDeletion/AccountDeletion'
import { useNavigate, useParams } from 'react-router'
const SettingsPage = () => {
@@ -21,6 +22,9 @@ const SettingsPage = () => {
}>
Notification Settings
+ }>
+ Account
+
@@ -29,6 +33,7 @@ const SettingsPage = () => {
{value === 'notifications' && }
+ {value === 'account' && }
)
}
diff --git a/client/src/pages/SettingsPage/components/AccountDeletion/AccountDeletion.tsx b/client/src/pages/SettingsPage/components/AccountDeletion/AccountDeletion.tsx
new file mode 100644
index 000000000..836bb9971
--- /dev/null
+++ b/client/src/pages/SettingsPage/components/AccountDeletion/AccountDeletion.tsx
@@ -0,0 +1,148 @@
+import React, { useEffect, useState } from 'react'
+import { Alert, Button, Group, Loader, Modal, Stack, Text, Title } from '@mantine/core'
+import { Warning } from '@phosphor-icons/react'
+import { doRequest } from '../../../../requests/request'
+import { showSimpleError, showSimpleSuccess } from '../../../../utils/notification'
+import { getApiResponseErrorMessage } from '../../../../requests/handler'
+import { useAuthenticationContext } from '../../../../hooks/authentication'
+
+interface IDeletionPreview {
+ canBeFullyDeleted: boolean
+ hasActiveTheses: boolean
+ retentionBlockedThesisCount: number
+ earliestFullDeletionDate?: string
+ isResearchGroupHead: boolean
+ message: string
+}
+
+interface IDeletionResult {
+ result: string
+ message: string
+}
+
+const AccountDeletion = () => {
+ const [preview, setPreview] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [deleting, setDeleting] = useState(false)
+ const [confirmOpen, setConfirmOpen] = useState(false)
+ const auth = useAuthenticationContext()
+
+ useEffect(() => {
+ const fetchPreview = async () => {
+ setLoading(true)
+ try {
+ const response = await doRequest('/v2/user-deletion/me/preview', {
+ method: 'GET',
+ requiresAuth: true,
+ })
+ if (response.ok) {
+ setPreview(response.data)
+ } else {
+ showSimpleError(getApiResponseErrorMessage(response))
+ }
+ } finally {
+ setLoading(false)
+ }
+ }
+ fetchPreview()
+ }, [])
+
+ const onDelete = async () => {
+ setConfirmOpen(false)
+ setDeleting(true)
+ try {
+ const response = await doRequest('/v2/user-deletion/me', {
+ method: 'DELETE',
+ requiresAuth: true,
+ })
+ if (response.ok) {
+ showSimpleSuccess(response.data.message)
+ auth.logout(window.location.origin)
+ } else {
+ showSimpleError(getApiResponseErrorMessage(response))
+ }
+ } finally {
+ setDeleting(false)
+ }
+ }
+
+ if (loading) {
+ return
+ }
+
+ if (!preview) {
+ return Failed to load deletion preview.
+ }
+
+ const canDelete = !preview.hasActiveTheses && !preview.isResearchGroupHead
+
+ return (
+
+ Delete Account
+ {preview.message}
+
+ {preview.isResearchGroupHead && (
+ } title='Research Group Head'>
+ You are currently head of a research group. Transfer leadership to another member before
+ deleting your account.
+
+ )}
+
+ {preview.hasActiveTheses && (
+ } title='Active Theses'>
+ You have active theses that must be completed or dropped before you can delete your
+ account.
+
+ )}
+
+ {!preview.canBeFullyDeleted && canDelete && preview.retentionBlockedThesisCount > 0 && (
+
+ Due to legal retention requirements, {preview.retentionBlockedThesisCount} thesis
+ record(s) and your profile data will be retained until{' '}
+ {preview.earliestFullDeletionDate
+ ? new Date(preview.earliestFullDeletionDate).toLocaleDateString()
+ : 'the retention period expires'}
+ . Your account will be deactivated and non-essential data deleted immediately.
+
+ )}
+
+
+ setConfirmOpen(true)}
+ >
+ Delete My Account
+
+
+
+ setConfirmOpen(false)}
+ title='Confirm Account Deletion'
+ >
+
+ }>
+ This action cannot be undone. Your account and personal data will be{' '}
+ {preview.canBeFullyDeleted
+ ? 'permanently deleted'
+ : 'deactivated, with full deletion after the retention period'}
+ .
+
+ Are you sure you want to proceed?
+
+ setConfirmOpen(false)}>
+ Cancel
+
+
+ Yes, Delete My Account
+
+
+
+
+
+ )
+}
+
+export default AccountDeletion
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index d42fda8fe..cda4025e6 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -75,5 +75,6 @@ Prioritized by urgency and impact on GDPR compliance.
### Priority 3 — Low (implement when capacity allows)
- [ ] **Automatic deletion/archival of thesis data after 5-year retention period**: Important for long-term compliance, but the 5-year clock means this is not urgent for recently created data. Can be implemented once the higher-priority items are in place.
+- [ ] **Snapshot application files at submission time**: Currently, CV (`user.cvFilename`), degree report (`user.degreeFilename`), and examination report (`user.examinationFilename`) are stored only on the User entity. If a student updates these files for a later application, the original files evaluated during an earlier thesis process are lost. Snapshot these file references onto the Application or Thesis at submission time. This would also allow immediate deletion of user-level files when a user requests account deletion during the 5-year retention period, because the snapshots on the retained thesis/application records would still be available for evaluation purposes.
- [ ] **Server-side consent tracking and privacy statement versioning**: The privacy notice consent checkbox currently stores consent only in the browser's localStorage as a UX convenience — once checked and submitted, the checkbox stays pre-ticked on future profile edits so users don't have to re-check it every time. This is not auditable and not persistent across browsers. Replace with server-side tracking: add a `privacyConsentedAt` timestamp and `privacyVersion` field to the User entity. When the user checks the box and submits, store the consent on the server. On future visits, pre-tick the checkbox based on the server record instead of localStorage. When the privacy statement is updated (new version), clear the consent and re-prompt users to agree to the new version.
- [ ] **Remove ProfilePictureMigration after successful production deployment**: One-time migration task that should be deleted once it has run successfully.
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/controller/UserDeletionController.java b/server/src/main/java/de/tum/cit/aet/thesis/controller/UserDeletionController.java
new file mode 100644
index 000000000..7dca4a581
--- /dev/null
+++ b/server/src/main/java/de/tum/cit/aet/thesis/controller/UserDeletionController.java
@@ -0,0 +1,57 @@
+package de.tum.cit.aet.thesis.controller;
+
+import de.tum.cit.aet.thesis.dto.UserDeletionPreviewDto;
+import de.tum.cit.aet.thesis.dto.UserDeletionResultDto;
+import de.tum.cit.aet.thesis.entity.User;
+import de.tum.cit.aet.thesis.service.AuthenticationService;
+import de.tum.cit.aet.thesis.service.UserDeletionService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.UUID;
+
+@Slf4j
+@RestController
+@RequestMapping("/v2/user-deletion")
+public class UserDeletionController {
+ private final UserDeletionService userDeletionService;
+ private final AuthenticationService authenticationService;
+
+ public UserDeletionController(UserDeletionService userDeletionService, AuthenticationService authenticationService) {
+ this.userDeletionService = userDeletionService;
+ this.authenticationService = authenticationService;
+ }
+
+ @GetMapping("/me/preview")
+ public ResponseEntity previewSelfDeletion(JwtAuthenticationToken jwt) {
+ User user = authenticationService.getAuthenticatedUser(jwt);
+ return ResponseEntity.ok(userDeletionService.previewDeletion(user.getId()));
+ }
+
+ @DeleteMapping("/me")
+ public ResponseEntity deleteSelf(JwtAuthenticationToken jwt) {
+ User user = authenticationService.getAuthenticatedUser(jwt);
+ UserDeletionResultDto result = userDeletionService.deleteOrAnonymizeUser(user.getId());
+ return ResponseEntity.ok(result);
+ }
+
+ @GetMapping("/{userId}/preview")
+ @PreAuthorize("hasRole('admin')")
+ public ResponseEntity previewUserDeletion(@PathVariable UUID userId) {
+ return ResponseEntity.ok(userDeletionService.previewDeletion(userId));
+ }
+
+ @DeleteMapping("/{userId}")
+ @PreAuthorize("hasRole('admin')")
+ public ResponseEntity deleteUser(@PathVariable UUID userId) {
+ UserDeletionResultDto result = userDeletionService.deleteOrAnonymizeUser(userId);
+ return ResponseEntity.ok(result);
+ }
+}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/dto/UserDeletionPreviewDto.java b/server/src/main/java/de/tum/cit/aet/thesis/dto/UserDeletionPreviewDto.java
new file mode 100644
index 000000000..aa2896d43
--- /dev/null
+++ b/server/src/main/java/de/tum/cit/aet/thesis/dto/UserDeletionPreviewDto.java
@@ -0,0 +1,16 @@
+package de.tum.cit.aet.thesis.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.time.Instant;
+
+@JsonInclude(JsonInclude.Include.NON_EMPTY)
+public record UserDeletionPreviewDto(
+ boolean canBeFullyDeleted,
+ boolean hasActiveTheses,
+ int retentionBlockedThesisCount,
+ Instant earliestFullDeletionDate,
+ boolean isResearchGroupHead,
+ String message
+) {
+}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/dto/UserDeletionResultDto.java b/server/src/main/java/de/tum/cit/aet/thesis/dto/UserDeletionResultDto.java
new file mode 100644
index 000000000..a3898ffc7
--- /dev/null
+++ b/server/src/main/java/de/tum/cit/aet/thesis/dto/UserDeletionResultDto.java
@@ -0,0 +1,7 @@
+package de.tum.cit.aet.thesis.dto;
+
+public record UserDeletionResultDto(
+ String result,
+ String message
+) {
+}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java b/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java
index d3e4fb99b..1a06a545a 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/entity/User.java
@@ -95,6 +95,15 @@ public class User {
@Column(name = "disabled", nullable = false)
private boolean disabled = false;
+ @Column(name = "anonymized_at")
+ private Instant anonymizedAt;
+
+ @Column(name = "deletion_requested_at")
+ private Instant deletionRequestedAt;
+
+ @Column(name = "deletion_scheduled_for")
+ private Instant deletionScheduledFor;
+
@Column(name = "enrolled_at")
private Instant enrolledAt;
@@ -137,6 +146,10 @@ public String getAdjustedAvatar() {
return null;
}
+ public boolean isAnonymized() {
+ return anonymizedAt != null;
+ }
+
public boolean hasNoGroup() {
return groups.isEmpty();
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/repository/ApplicationRepository.java b/server/src/main/java/de/tum/cit/aet/thesis/repository/ApplicationRepository.java
index 96ebe9f22..c5e163f52 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/repository/ApplicationRepository.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/repository/ApplicationRepository.java
@@ -100,4 +100,11 @@ boolean existsPendingApplication(
List findAllByTopic(Topic topic);
List findAllByUser(User user);
+
+ List findAllByUserId(UUID userId);
+
+ @Modifying
+ @Transactional
+ @Query("DELETE FROM Application a WHERE a.user.id = :userId")
+ void deleteAllByUserId(@Param("userId") UUID userId);
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/repository/ResearchGroupRepository.java b/server/src/main/java/de/tum/cit/aet/thesis/repository/ResearchGroupRepository.java
index d81094920..8013cc5d4 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/repository/ResearchGroupRepository.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/repository/ResearchGroupRepository.java
@@ -32,5 +32,7 @@ Page searchResearchGroup(
);
@Query("SELECT r FROM ResearchGroup r WHERE r.abbreviation = :abbreviation")
-ResearchGroup findByAbbreviation(String abbreviation);
+ ResearchGroup findByAbbreviation(String abbreviation);
+
+ boolean existsByHeadId(UUID headId);
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/repository/ThesisRoleRepository.java b/server/src/main/java/de/tum/cit/aet/thesis/repository/ThesisRoleRepository.java
index 51b904508..af55e4269 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/repository/ThesisRoleRepository.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/repository/ThesisRoleRepository.java
@@ -12,4 +12,8 @@
@Repository
public interface ThesisRoleRepository extends JpaRepository {
List deleteByThesisId(UUID thesisId);
+
+ List findAllByIdUserId(UUID userId);
+
+ void deleteAllByIdUserId(UUID userId);
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/repository/TopicRoleRepository.java b/server/src/main/java/de/tum/cit/aet/thesis/repository/TopicRoleRepository.java
index 98a13fa36..444ea492c 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/repository/TopicRoleRepository.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/repository/TopicRoleRepository.java
@@ -3,6 +3,7 @@
import de.tum.cit.aet.thesis.entity.TopicRole;
import de.tum.cit.aet.thesis.entity.key.TopicRoleId;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
import org.springframework.stereotype.Repository;
import java.util.List;
@@ -12,4 +13,7 @@
@Repository
public interface TopicRoleRepository extends JpaRepository {
List deleteByTopicId(UUID topicId);
+
+ @Modifying
+ void deleteAllByIdUserId(UUID userId);
}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java b/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java
index f9c3faf38..0d56e0292 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/repository/UserRepository.java
@@ -67,6 +67,8 @@ AND t.state NOT IN ('FINISHED', 'DROPPED_OUT')
""")
List findStudentsWithActiveThesesByResearchGroupId(@Param("researchGroupId") UUID researchGroupId);
+ List findAllByDeletionRequestedAtIsNotNull();
+
@Query("""
SELECT DISTINCT u FROM User u
JOIN UserGroup g ON u.id = g.id.userId AND g.id.group = 'student'
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/AuthenticationService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/AuthenticationService.java
index 4c1bcb1ae..0cffa65af 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/AuthenticationService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/AuthenticationService.java
@@ -93,6 +93,11 @@ public User updateAuthenticatedUser(JwtAuthenticationToken jwt) {
return newUser;
});
+ if (user.isAnonymized() || user.getDeletionRequestedAt() != null) {
+ throw new de.tum.cit.aet.thesis.exception.request.AccessDeniedException(
+ "This account has been deleted and can no longer be used");
+ }
+
user.setUniversityId(universityId);
if (email != null && !email.isEmpty()) {
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/DataRetentionService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/DataRetentionService.java
index b2eb580bb..c409e012c 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/DataRetentionService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/DataRetentionService.java
@@ -21,17 +21,20 @@ public class DataRetentionService {
private final ApplicationRepository applicationRepository;
private final UserRepository userRepository;
private final DataExportService dataExportService;
+ private final UserDeletionService userDeletionService;
private final int retentionDays;
private final int inactiveUserDays;
public DataRetentionService(ApplicationRepository applicationRepository,
UserRepository userRepository,
DataExportService dataExportService,
+ UserDeletionService userDeletionService,
@Value("${thesis-management.data-retention.rejected-application-retention-days}") int retentionDays,
@Value("${thesis-management.data-retention.inactive-user-days}") int inactiveUserDays) {
this.applicationRepository = applicationRepository;
this.userRepository = userRepository;
this.dataExportService = dataExportService;
+ this.userDeletionService = userDeletionService;
this.retentionDays = retentionDays;
this.inactiveUserDays = inactiveUserDays;
}
@@ -42,6 +45,7 @@ public void runNightlyCleanup() {
disableInactiveUsers();
dataExportService.processAllPendingExports();
dataExportService.deleteExpiredExports();
+ userDeletionService.processDeferredDeletions();
}
public int disableInactiveUsers() {
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/UploadService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/UploadService.java
index 7a95ff478..19ed62ea1 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/UploadService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/UploadService.java
@@ -151,6 +151,17 @@ public String storeBytes(byte[] bytes, String extension, int maxSize) {
}
}
+ public void deleteFile(String filename) {
+ if (filename == null || filename.isBlank()) {
+ return;
+ }
+ try {
+ Files.deleteIfExists(rootLocation.resolve(filename));
+ } catch (IOException e) {
+ // Log but don't throw — best-effort file cleanup
+ }
+ }
+
private String computeFileHash(MultipartFile file) throws IOException, NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream inputStream = file.getInputStream()) {
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/UserDeletionService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/UserDeletionService.java
new file mode 100644
index 000000000..55644a572
--- /dev/null
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/UserDeletionService.java
@@ -0,0 +1,275 @@
+package de.tum.cit.aet.thesis.service;
+
+import de.tum.cit.aet.thesis.constants.ApplicationState;
+import de.tum.cit.aet.thesis.constants.ThesisState;
+import de.tum.cit.aet.thesis.dto.UserDeletionPreviewDto;
+import de.tum.cit.aet.thesis.dto.UserDeletionResultDto;
+import de.tum.cit.aet.thesis.entity.Application;
+import de.tum.cit.aet.thesis.entity.DataExport;
+import de.tum.cit.aet.thesis.entity.ThesisRole;
+import de.tum.cit.aet.thesis.entity.User;
+import de.tum.cit.aet.thesis.exception.request.AccessDeniedException;
+import de.tum.cit.aet.thesis.exception.request.ResourceNotFoundException;
+import de.tum.cit.aet.thesis.repository.ApplicationRepository;
+import de.tum.cit.aet.thesis.repository.DataExportRepository;
+import de.tum.cit.aet.thesis.repository.NotificationSettingRepository;
+import de.tum.cit.aet.thesis.repository.ResearchGroupRepository;
+import de.tum.cit.aet.thesis.repository.ThesisRoleRepository;
+import de.tum.cit.aet.thesis.repository.TopicRoleRepository;
+import de.tum.cit.aet.thesis.repository.UserGroupRepository;
+import de.tum.cit.aet.thesis.repository.UserRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+
+@Service
+public class UserDeletionService {
+ private static final Logger log = LoggerFactory.getLogger(UserDeletionService.class);
+ private static final int RETENTION_YEARS = 5;
+ private static final Set TERMINAL_STATES = Set.of(ThesisState.FINISHED, ThesisState.DROPPED_OUT);
+
+ private final UserRepository userRepository;
+ private final ThesisRoleRepository thesisRoleRepository;
+ private final TopicRoleRepository topicRoleRepository;
+ private final ApplicationRepository applicationRepository;
+ private final ResearchGroupRepository researchGroupRepository;
+ private final DataExportRepository dataExportRepository;
+ private final UserGroupRepository userGroupRepository;
+ private final NotificationSettingRepository notificationSettingRepository;
+ private final UploadService uploadService;
+
+ public UserDeletionService(
+ UserRepository userRepository,
+ ThesisRoleRepository thesisRoleRepository,
+ TopicRoleRepository topicRoleRepository,
+ ApplicationRepository applicationRepository,
+ ResearchGroupRepository researchGroupRepository,
+ DataExportRepository dataExportRepository,
+ UserGroupRepository userGroupRepository,
+ NotificationSettingRepository notificationSettingRepository,
+ UploadService uploadService) {
+ this.userRepository = userRepository;
+ this.thesisRoleRepository = thesisRoleRepository;
+ this.topicRoleRepository = topicRoleRepository;
+ this.applicationRepository = applicationRepository;
+ this.researchGroupRepository = researchGroupRepository;
+ this.dataExportRepository = dataExportRepository;
+ this.userGroupRepository = userGroupRepository;
+ this.notificationSettingRepository = notificationSettingRepository;
+ this.uploadService = uploadService;
+ }
+
+ public UserDeletionPreviewDto previewDeletion(UUID userId) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new ResourceNotFoundException("User not found"));
+
+ boolean isResearchGroupHead = researchGroupRepository.existsByHeadId(userId);
+ boolean hasActiveTheses = hasActiveTheses(userId);
+ List retentionBlockedRoles = getRetentionBlockedThesisRoles(userId);
+ int retentionBlockedCount = (int) retentionBlockedRoles.stream()
+ .map(r -> r.getThesis().getId())
+ .distinct()
+ .count();
+ Instant earliestDeletion = computeEarliestFullDeletion(retentionBlockedRoles);
+ boolean canBeFullyDeleted = !hasActiveTheses && !isResearchGroupHead && retentionBlockedCount == 0;
+
+ String message;
+ if (isResearchGroupHead) {
+ message = "You must transfer research group leadership before deleting your account.";
+ } else if (hasActiveTheses) {
+ message = "You have active theses that must be completed or dropped before deletion.";
+ } else if (canBeFullyDeleted) {
+ message = "Your account and all associated data will be permanently deleted.";
+ } else {
+ message = "Your account will be deactivated and non-essential data deleted immediately. "
+ + "Your profile and thesis data (" + retentionBlockedCount
+ + " thesis/theses) must be retained until " + earliestDeletion
+ + " per legal requirements, then everything will be fully deleted.";
+ }
+
+ return new UserDeletionPreviewDto(
+ canBeFullyDeleted,
+ hasActiveTheses,
+ retentionBlockedCount,
+ earliestDeletion,
+ isResearchGroupHead,
+ message
+ );
+ }
+
+ @Transactional
+ public UserDeletionResultDto deleteOrAnonymizeUser(UUID userId) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new ResourceNotFoundException("User not found"));
+
+ if (user.isAnonymized() || user.getDeletionRequestedAt() != null) {
+ throw new AccessDeniedException("This account has already been deleted");
+ }
+
+ if (researchGroupRepository.existsByHeadId(userId)) {
+ throw new AccessDeniedException("Cannot delete account while being a research group head. Transfer leadership first.");
+ }
+
+ if (hasActiveTheses(userId)) {
+ throw new AccessDeniedException("Cannot delete account with active theses. Complete or drop out first.");
+ }
+
+ // Delete freely-deletable applications (rejected/not-assessed)
+ deleteNonRetainedApplications(userId);
+
+ // Delete data exports (files + records)
+ deleteDataExports(user);
+
+ List retentionBlockedRoles = getRetentionBlockedThesisRoles(userId);
+
+ if (retentionBlockedRoles.isEmpty()) {
+ // No retention — delete all user files and the account
+ deleteAllUserFiles(user);
+ return performFullDeletion(user);
+ } else {
+ // Retention active — only delete avatar (cosmetic), keep CV/degree/exam
+ // as they are part of the thesis evaluation process.
+ // TODO: Once application file snapshotting is implemented (i.e. copying cvFilename,
+ // degreeFilename, and examinationFilename onto the Application or Thesis at submission
+ // time), we can delete user.cvFilename, user.degreeFilename, and
+ // user.examinationFilename here as well, because the snapshots on the retained
+ // thesis/application records would still be available for evaluation purposes.
+ uploadService.deleteFile(user.getAvatar());
+ return performSoftDeletion(user, retentionBlockedRoles);
+ }
+ }
+
+ @Transactional
+ public void processDeferredDeletions() {
+ List pendingUsers = userRepository.findAllByDeletionRequestedAtIsNotNull();
+
+ for (User user : pendingUsers) {
+ List retentionBlocked = getRetentionBlockedThesisRoles(user.getId());
+ if (retentionBlocked.isEmpty()) {
+ log.info("Retention expired for user {}, performing full deletion", user.getId());
+ deleteAllUserFiles(user);
+ deleteDataExports(user);
+ performFullDeletion(user);
+ }
+ }
+ }
+
+ private UserDeletionResultDto performFullDeletion(User user) {
+ UUID userId = user.getId();
+
+ // Delete remaining applications
+ applicationRepository.deleteAllByUserId(userId);
+
+ // Delete topic roles
+ topicRoleRepository.deleteAllByIdUserId(userId);
+
+ // Delete thesis roles (should be empty if no retention-blocked data)
+ thesisRoleRepository.deleteAllByIdUserId(userId);
+
+ // Delete user record (FK cascades handle notification_settings, user_groups, data_exports)
+ userRepository.delete(user);
+
+ log.info("Fully deleted user account {}", userId);
+ return new UserDeletionResultDto("DELETED", "Your account and all associated data have been permanently deleted.");
+ }
+
+ private UserDeletionResultDto performSoftDeletion(User user, List retentionBlockedRoles) {
+ Instant now = Instant.now();
+ Instant earliestDeletion = computeEarliestFullDeletion(retentionBlockedRoles);
+
+ // Deactivate the account but keep profile data intact so thesis records
+ // remain searchable by name during the legal retention period.
+ user.setDisabled(true);
+ user.setDeletionRequestedAt(now);
+ user.setDeletionScheduledFor(earliestDeletion);
+
+ // Delete non-essential data that is not needed for thesis retention.
+ // Keep CV, degree report, and examination report as they are part of
+ // the thesis evaluation process and may still need to be referenced.
+ user.setAvatar(null);
+ user.setProjects(null);
+ user.setInterests(null);
+ user.setSpecialSkills(null);
+ user.setCustomData(new HashMap<>());
+
+ userRepository.save(user);
+
+ // Delete notification settings and user groups (not needed during retention)
+ notificationSettingRepository.deleteAll(user.getNotificationSettings());
+ userGroupRepository.deleteByUserId(user.getId());
+
+ log.info("Soft-deleted user account {}, full deletion scheduled for {}", user.getId(), earliestDeletion);
+ return new UserDeletionResultDto("DEACTIVATED",
+ "Your account has been deactivated and non-essential data deleted. "
+ + "Your profile and thesis data will be fully deleted after the legal retention period expires ("
+ + earliestDeletion + ").");
+ }
+
+ private void deleteAllUserFiles(User user) {
+ uploadService.deleteFile(user.getCvFilename());
+ uploadService.deleteFile(user.getDegreeFilename());
+ uploadService.deleteFile(user.getExaminationFilename());
+ uploadService.deleteFile(user.getAvatar());
+ }
+
+ private boolean hasActiveTheses(UUID userId) {
+ return thesisRoleRepository.findAllByIdUserId(userId).stream()
+ .anyMatch(role -> !TERMINAL_STATES.contains(role.getThesis().getState()));
+ }
+
+ private List getRetentionBlockedThesisRoles(UUID userId) {
+ Instant now = Instant.now();
+ return thesisRoleRepository.findAllByIdUserId(userId).stream()
+ .filter(role -> TERMINAL_STATES.contains(role.getThesis().getState()))
+ .filter(role -> computeRetentionExpiry(role).isAfter(now))
+ .toList();
+ }
+
+ private Instant computeRetentionExpiry(ThesisRole role) {
+ // Retention: 5 years after end of calendar year of thesis completion
+ Instant createdAt = role.getThesis().getCreatedAt();
+ ZonedDateTime zdt = createdAt.atZone(ZoneId.of("Europe/Berlin"));
+ // End of the calendar year + 5 years
+ return ZonedDateTime.of(zdt.getYear() + RETENTION_YEARS, 12, 31, 23, 59, 59, 0, ZoneId.of("Europe/Berlin"))
+ .toInstant();
+ }
+
+ private Instant computeEarliestFullDeletion(List retentionBlockedRoles) {
+ return retentionBlockedRoles.stream()
+ .map(this::computeRetentionExpiry)
+ .max(Instant::compareTo)
+ .orElse(null);
+ }
+
+ private void deleteNonRetainedApplications(UUID userId) {
+ List applications = applicationRepository.findAllByUserId(userId);
+ for (Application app : applications) {
+ if (app.getState() == ApplicationState.REJECTED || app.getState() == ApplicationState.NOT_ASSESSED) {
+ applicationRepository.deleteApplicationById(app.getId());
+ }
+ }
+ }
+
+ private void deleteDataExports(User user) {
+ List exports = dataExportRepository.findAllByUserOrderByCreatedAtDesc(user);
+ for (DataExport export : exports) {
+ if (export.getFilePath() != null) {
+ try {
+ java.nio.file.Files.deleteIfExists(java.nio.file.Path.of(export.getFilePath()));
+ } catch (java.io.IOException e) {
+ log.warn("Failed to delete export file {}: {}", export.getFilePath(), e.getMessage());
+ }
+ }
+ dataExportRepository.delete(export);
+ }
+ }
+}
diff --git a/server/src/main/resources/db/changelog/changes/30_user_deletion.sql b/server/src/main/resources/db/changelog/changes/30_user_deletion.sql
new file mode 100644
index 000000000..f19cb9114
--- /dev/null
+++ b/server/src/main/resources/db/changelog/changes/30_user_deletion.sql
@@ -0,0 +1,85 @@
+--liquibase formatted sql
+
+--changeset thesis:30-user-deletion-1
+ALTER TABLE users ADD COLUMN anonymized_at TIMESTAMP;
+ALTER TABLE users ADD COLUMN deletion_requested_at TIMESTAMP;
+ALTER TABLE users ADD COLUMN deletion_scheduled_for TIMESTAMP;
+
+--changeset thesis:30-user-deletion-2
+-- ON DELETE CASCADE for user-owned metadata (no retention needed)
+ALTER TABLE notification_settings DROP CONSTRAINT IF EXISTS notification_settings_user_id_fkey;
+ALTER TABLE notification_settings ADD CONSTRAINT notification_settings_user_id_fkey
+ FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE;
+
+ALTER TABLE user_groups DROP CONSTRAINT IF EXISTS user_groups_user_id_fkey;
+ALTER TABLE user_groups ADD CONSTRAINT user_groups_user_id_fkey
+ FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE;
+
+ALTER TABLE data_exports DROP CONSTRAINT IF EXISTS data_exports_user_id_fkey;
+ALTER TABLE data_exports ADD CONSTRAINT data_exports_user_id_fkey
+ FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE;
+
+--changeset thesis:30-user-deletion-3
+-- ON DELETE SET NULL for audit references on retained records
+-- thesis_assessments.created_by
+ALTER TABLE thesis_assessments ALTER COLUMN created_by DROP NOT NULL;
+ALTER TABLE thesis_assessments DROP CONSTRAINT IF EXISTS thesis_assessments_created_by_fkey;
+ALTER TABLE thesis_assessments ADD CONSTRAINT thesis_assessments_created_by_fkey
+ FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE SET NULL;
+
+-- thesis_comments.created_by
+ALTER TABLE thesis_comments ALTER COLUMN created_by DROP NOT NULL;
+ALTER TABLE thesis_comments DROP CONSTRAINT IF EXISTS thesis_comments_created_by_fkey;
+ALTER TABLE thesis_comments ADD CONSTRAINT thesis_comments_created_by_fkey
+ FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE SET NULL;
+
+-- thesis_feedback.requested_by
+ALTER TABLE thesis_feedback ALTER COLUMN requested_by DROP NOT NULL;
+ALTER TABLE thesis_feedback DROP CONSTRAINT IF EXISTS thesis_feedback_requested_by_fkey;
+ALTER TABLE thesis_feedback ADD CONSTRAINT thesis_feedback_requested_by_fkey
+ FOREIGN KEY (requested_by) REFERENCES users (user_id) ON DELETE SET NULL;
+
+-- thesis_files.uploaded_by
+ALTER TABLE thesis_files ALTER COLUMN uploaded_by DROP NOT NULL;
+ALTER TABLE thesis_files DROP CONSTRAINT IF EXISTS thesis_files_uploaded_by_fkey;
+ALTER TABLE thesis_files ADD CONSTRAINT thesis_files_uploaded_by_fkey
+ FOREIGN KEY (uploaded_by) REFERENCES users (user_id) ON DELETE SET NULL;
+
+-- thesis_proposals.created_by
+ALTER TABLE thesis_proposals ALTER COLUMN created_by DROP NOT NULL;
+ALTER TABLE thesis_proposals DROP CONSTRAINT IF EXISTS thesis_proposals_created_by_fkey;
+ALTER TABLE thesis_proposals ADD CONSTRAINT thesis_proposals_created_by_fkey
+ FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE SET NULL;
+
+-- topics.created_by
+ALTER TABLE topics ALTER COLUMN created_by DROP NOT NULL;
+ALTER TABLE topics DROP CONSTRAINT IF EXISTS topics_created_by_fkey;
+ALTER TABLE topics ADD CONSTRAINT topics_created_by_fkey
+ FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE SET NULL;
+
+-- email_templates.updated_by (already nullable from 09_email_templates.sql)
+ALTER TABLE email_templates DROP CONSTRAINT IF EXISTS email_templates_updated_by_fkey;
+ALTER TABLE email_templates ADD CONSTRAINT email_templates_updated_by_fkey
+ FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE SET NULL;
+
+-- research_groups.created_by (already nullable from 08_research_groups.sql)
+ALTER TABLE research_groups DROP CONSTRAINT IF EXISTS research_groups_created_by_fkey;
+ALTER TABLE research_groups ADD CONSTRAINT research_groups_created_by_fkey
+ FOREIGN KEY (created_by) REFERENCES users (user_id) ON DELETE SET NULL;
+
+-- research_groups.updated_by (already nullable from 08_research_groups.sql)
+ALTER TABLE research_groups DROP CONSTRAINT IF EXISTS research_groups_updated_by_fkey;
+ALTER TABLE research_groups ADD CONSTRAINT research_groups_updated_by_fkey
+ FOREIGN KEY (updated_by) REFERENCES users (user_id) ON DELETE SET NULL;
+
+-- topic_roles.assigned_by
+ALTER TABLE topic_roles ALTER COLUMN assigned_by DROP NOT NULL;
+ALTER TABLE topic_roles DROP CONSTRAINT IF EXISTS topic_roles_assigned_by_fkey;
+ALTER TABLE topic_roles ADD CONSTRAINT topic_roles_assigned_by_fkey
+ FOREIGN KEY (assigned_by) REFERENCES users (user_id) ON DELETE SET NULL;
+
+-- thesis_roles.assigned_by
+ALTER TABLE thesis_roles ALTER COLUMN assigned_by DROP NOT NULL;
+ALTER TABLE thesis_roles DROP CONSTRAINT IF EXISTS thesis_roles_assigned_by_fkey;
+ALTER TABLE thesis_roles ADD CONSTRAINT thesis_roles_assigned_by_fkey
+ FOREIGN KEY (assigned_by) REFERENCES users (user_id) ON DELETE SET NULL;
diff --git a/server/src/main/resources/db/changelog/db.changelog-master.xml b/server/src/main/resources/db/changelog/db.changelog-master.xml
index 21c2d9ad7..a558e1479 100644
--- a/server/src/main/resources/db/changelog/db.changelog-master.xml
+++ b/server/src/main/resources/db/changelog/db.changelog-master.xml
@@ -33,4 +33,5 @@
+
From 1d05e43556c2a98fac1ab59f2a8d7a90022c0188 Mon Sep 17 00:00:00 2001
From: Stephan Krusche
Date: Mon, 23 Feb 2026 21:20:45 +0100
Subject: [PATCH 21/39] add server and e2e tests
---
client/e2e/account-deletion.spec.ts | 263 +++++++++
client/e2e/auth.setup.ts | 3 +
docs/DATA_RETENTION.md | 50 +-
keycloak/thesis-management-realm.json | 131 ++++-
.../repository/ThesisRoleRepository.java | 5 +
.../thesis/service/UserDeletionService.java | 19 +-
.../changes/23_seed_dev_test_data.xml | 2 +-
.../changelog/manual/seed_dev_test_data.sql | 141 +++++
.../service/UserDeletionServiceTest.java | 531 ++++++++++++++++++
9 files changed, 1126 insertions(+), 19 deletions(-)
create mode 100644 client/e2e/account-deletion.spec.ts
create mode 100644 server/src/test/java/de/tum/cit/aet/thesis/service/UserDeletionServiceTest.java
diff --git a/client/e2e/account-deletion.spec.ts b/client/e2e/account-deletion.spec.ts
new file mode 100644
index 000000000..1c0392529
--- /dev/null
+++ b/client/e2e/account-deletion.spec.ts
@@ -0,0 +1,263 @@
+import { test, expect } from '@playwright/test'
+import { authStatePath, navigateTo } from './helpers'
+
+// ============================================================================
+// Self-service account deletion (Settings > Account tab)
+//
+// NOTE: Destructive tests (actual deletion) check if the user was already
+// deleted in a prior run and skip gracefully, similar to data-retention tests.
+// ============================================================================
+
+/**
+ * Helper: navigate to the Account tab and check if the user's account
+ * is still active (i.e. the Delete Account heading loads). Returns false
+ * if the user was already deleted/deactivated in a prior run.
+ */
+async function navigateToAccountTab(page: import('@playwright/test').Page): Promise {
+ await navigateTo(page, '/settings/account')
+ const heading = page.getByRole('heading', { name: 'Delete Account' })
+ return heading.isVisible({ timeout: 15_000 }).catch(() => false)
+}
+
+test.describe('Account Deletion - Self-Service (Full Deletion)', () => {
+ test.use({ storageState: authStatePath('delete_rejected_app') })
+ test.describe.configure({ mode: 'serial' })
+
+ test('account tab shows deletion preview for user with rejected application', async ({
+ page,
+ }) => {
+ const isActive = await navigateToAccountTab(page)
+ if (!isActive) return // User already deleted in a prior run
+
+ // User with only a rejected application should see full deletion message
+ await expect(page.getByText(/permanently deleted/i)).toBeVisible({ timeout: 10_000 })
+
+ // Delete button should be enabled
+ const deleteButton = page.getByRole('button', { name: 'Delete My Account' })
+ await expect(deleteButton).toBeVisible()
+ await expect(deleteButton).toBeEnabled()
+ })
+
+ test('user can delete their own account', async ({ page }) => {
+ const isActive = await navigateToAccountTab(page)
+ if (!isActive) return
+
+ const deleteButton = page.getByRole('button', { name: 'Delete My Account' })
+ await expect(deleteButton).toBeEnabled({ timeout: 10_000 })
+ await deleteButton.click()
+
+ // Confirmation modal should appear
+ await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 })
+ await expect(page.getByText('Are you sure you want to proceed?')).toBeVisible()
+
+ // Confirm deletion
+ await page.getByRole('dialog').getByRole('button', { name: 'Yes, Delete My Account' }).click()
+
+ // Should redirect to login (Keycloak) after logout
+ await expect(page).toHaveURL(/localhost:3000|kc-login/, { timeout: 30_000 })
+ })
+})
+
+test.describe('Account Deletion - Self-Service (Soft Deletion / Retention)', () => {
+ test.use({ storageState: authStatePath('delete_recent_thesis') })
+ test.describe.configure({ mode: 'serial' })
+
+ test('account tab shows retention notice for user with recent thesis', async ({ page }) => {
+ const isActive = await navigateToAccountTab(page)
+ if (!isActive) return
+
+ // Should show data retention notice
+ await expect(page.getByText('Data Retention Notice', { exact: true })).toBeVisible({
+ timeout: 10_000,
+ })
+ await expect(page.getByText(/legal retention requirements/i)).toBeVisible()
+
+ // Delete button should still be enabled (soft deletion is allowed)
+ const deleteButton = page.getByRole('button', { name: 'Delete My Account' })
+ await expect(deleteButton).toBeEnabled()
+ })
+
+ test('user with recent thesis can soft-delete their account', async ({ page }) => {
+ const isActive = await navigateToAccountTab(page)
+ if (!isActive) return
+
+ const deleteButton = page.getByRole('button', { name: 'Delete My Account' })
+ await expect(deleteButton).toBeEnabled({ timeout: 10_000 })
+ await deleteButton.click()
+
+ // Modal should mention deactivation (not permanent deletion)
+ const dialog = page.getByRole('dialog')
+ await expect(dialog).toBeVisible({ timeout: 5_000 })
+ await expect(dialog.getByText(/deactivated/i)).toBeVisible()
+
+ await page.getByRole('dialog').getByRole('button', { name: 'Yes, Delete My Account' }).click()
+
+ // Should redirect after logout
+ await expect(page).toHaveURL(/localhost:3000|kc-login/, { timeout: 30_000 })
+ })
+})
+
+test.describe('Account Deletion - Self-Service (Expired Retention)', () => {
+ test.use({ storageState: authStatePath('delete_old_thesis') })
+ test.describe.configure({ mode: 'serial' })
+
+ test('account tab shows full deletion for user with old thesis (retention expired)', async ({
+ page,
+ }) => {
+ const isActive = await navigateToAccountTab(page)
+ if (!isActive) return
+
+ // Retention has expired — should show full deletion message
+ await expect(page.getByText(/permanently deleted/i)).toBeVisible({ timeout: 10_000 })
+
+ // No retention notice should be shown
+ await expect(page.getByText(/Data Retention Notice/i)).not.toBeVisible({ timeout: 3_000 })
+
+ const deleteButton = page.getByRole('button', { name: 'Delete My Account' })
+ await expect(deleteButton).toBeEnabled()
+ })
+
+ test('user with old thesis can fully delete their account', async ({ page }) => {
+ const isActive = await navigateToAccountTab(page)
+ if (!isActive) return
+
+ const deleteButton = page.getByRole('button', { name: 'Delete My Account' })
+ await expect(deleteButton).toBeEnabled({ timeout: 10_000 })
+ await deleteButton.click()
+
+ await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 })
+ await page.getByRole('dialog').getByRole('button', { name: 'Yes, Delete My Account' }).click()
+
+ await expect(page).toHaveURL(/localhost:3000|kc-login/, { timeout: 30_000 })
+ })
+})
+
+// ============================================================================
+// Settings page: Account tab visibility
+// ============================================================================
+
+test.describe('Settings - Account Tab', () => {
+ test('account tab is visible on settings page', async ({ page }) => {
+ await navigateTo(page, '/settings')
+
+ await expect(page.getByText('Account')).toBeVisible({ timeout: 15_000 })
+ await expect(page.getByText('My Information')).toBeVisible()
+ await expect(page.getByText('Notification Settings')).toBeVisible()
+ })
+
+ test('navigating to account tab shows deletion content', async ({ page }) => {
+ await navigateTo(page, '/settings/account')
+
+ await expect(page.getByRole('heading', { name: 'Delete Account' })).toBeVisible({
+ timeout: 15_000,
+ })
+ await expect(page.getByRole('button', { name: 'Delete My Account' })).toBeVisible()
+ })
+})
+
+// ============================================================================
+// Active thesis blocks deletion
+// ============================================================================
+
+test.describe('Account Deletion - Active Thesis Blocks', () => {
+ // student has an active thesis (WRITING state)
+ test.use({ storageState: authStatePath('student') })
+
+ test('account tab shows active thesis warning and disables delete', async ({ page }) => {
+ await navigateTo(page, '/settings/account')
+
+ await expect(page.getByRole('heading', { name: 'Delete Account' })).toBeVisible({
+ timeout: 15_000,
+ })
+
+ // Active thesis warning should be visible
+ await expect(page.getByText('Active Theses', { exact: true })).toBeVisible({
+ timeout: 10_000,
+ })
+
+ // Delete button should be disabled
+ const deleteButton = page.getByRole('button', { name: 'Delete My Account' })
+ await expect(deleteButton).toBeDisabled()
+ })
+})
+
+// ============================================================================
+// Research group head blocks deletion
+// ============================================================================
+
+test.describe('Account Deletion - Research Group Head Blocks', () => {
+ // supervisor is head of ASE research group
+ test.use({ storageState: authStatePath('supervisor') })
+
+ test('account tab shows research group head warning and disables delete', async ({ page }) => {
+ await navigateTo(page, '/settings/account')
+
+ await expect(page.getByRole('heading', { name: 'Delete Account' })).toBeVisible({
+ timeout: 15_000,
+ })
+
+ // Research group head warning should be visible
+ await expect(page.getByText('Research Group Head', { exact: true })).toBeVisible({
+ timeout: 10_000,
+ })
+
+ // Delete button should be disabled
+ const deleteButton = page.getByRole('button', { name: 'Delete My Account' })
+ await expect(deleteButton).toBeDisabled()
+ })
+})
+
+// ============================================================================
+// Admin user deletion
+// ============================================================================
+
+test.describe('Account Deletion - Admin Operations', () => {
+ test.use({ storageState: authStatePath('admin') })
+
+ test('admin page shows user deletion section', async ({ page }) => {
+ await navigateTo(page, '/admin')
+
+ await expect(page.getByRole('heading', { name: 'Administration' })).toBeVisible({
+ timeout: 30_000,
+ })
+ await expect(page.getByRole('heading', { name: 'User Account Deletion' })).toBeVisible()
+ await expect(page.getByPlaceholder(/Search by name, email, or ID/i)).toBeVisible()
+ })
+
+ test('admin can search for users', async ({ page }) => {
+ await navigateTo(page, '/admin')
+
+ await expect(page.getByRole('heading', { name: 'User Account Deletion' })).toBeVisible({
+ timeout: 30_000,
+ })
+
+ const searchInput = page.getByPlaceholder(/Search by name, email, or ID/i)
+ await searchInput.fill('Student')
+ await page.getByRole('button', { name: 'Search' }).click()
+
+ // Should show search results
+ await expect(page.getByRole('button', { name: /Student.*User/i }).first()).toBeVisible({
+ timeout: 15_000,
+ })
+ })
+
+ test('admin can preview deletion for a user', async ({ page }) => {
+ await navigateTo(page, '/admin')
+
+ await expect(page.getByRole('heading', { name: 'User Account Deletion' })).toBeVisible({
+ timeout: 30_000,
+ })
+
+ // Search for student5 (has DROPPED_OUT thesis → retention blocked)
+ const searchInput = page.getByPlaceholder(/Search by name, email, or ID/i)
+ await searchInput.fill('Student5')
+ await page.getByRole('button', { name: 'Search' }).click()
+
+ const userButton = page.getByRole('button', { name: /Student5.*User/i })
+ await expect(userButton).toBeVisible({ timeout: 15_000 })
+ await userButton.click()
+
+ // Deletion preview should show
+ await expect(page.getByText(/Deletion preview for/i)).toBeVisible({ timeout: 15_000 })
+ })
+})
diff --git a/client/e2e/auth.setup.ts b/client/e2e/auth.setup.ts
index 428f94646..f16119a63 100644
--- a/client/e2e/auth.setup.ts
+++ b/client/e2e/auth.setup.ts
@@ -9,6 +9,9 @@ const TEST_USERS = [
{ name: 'supervisor', username: 'supervisor', password: 'supervisor' },
{ name: 'supervisor2', username: 'supervisor2', password: 'supervisor2' },
{ name: 'admin', username: 'admin', password: 'admin' },
+ { name: 'delete_old_thesis', username: 'delete_old_thesis', password: 'delete_old_thesis' },
+ { name: 'delete_recent_thesis', username: 'delete_recent_thesis', password: 'delete_recent_thesis' },
+ { name: 'delete_rejected_app', username: 'delete_rejected_app', password: 'delete_rejected_app' },
] as const
for (const user of TEST_USERS) {
diff --git a/docs/DATA_RETENTION.md b/docs/DATA_RETENTION.md
index cda4025e6..813d1759a 100644
--- a/docs/DATA_RETENTION.md
+++ b/docs/DATA_RETENTION.md
@@ -55,6 +55,52 @@ To address this, the application includes a time-based expiration mechanism that
1. **Student transparency**: Without expiration, students whose applications are never reviewed would wait indefinitely without any response. The automatic rejection ensures they are notified and can reapply or look for alternatives.
2. **Data minimization**: The expiration assigns a rejection date, which starts the 1-year retention clock and enables eventual data cleanup.
+## Account Deletion Implementation
+
+Self-service account deletion is available via **Settings > Account** and admin deletion via the **Administration** page. The system handles two scenarios depending on whether the user has thesis data under legal retention.
+
+### Scenario A: No Retention-Blocked Data
+
+When the user has no completed theses (or all thesis retention periods have expired), the account is **fully deleted**:
+
+1. All uploaded files (CV, degree report, examination report, avatar) are deleted from disk.
+2. Rejected/unassessed applications and data exports are deleted.
+3. Topic roles, thesis roles, and remaining applications are deleted.
+4. The user record is deleted (FK cascades remove notification settings, user groups, and data exports).
+
+### Scenario B: Thesis Data Under Retention
+
+When the user has completed theses within the 5-year retention window, the account is **soft-deleted** (deactivated):
+
+1. The account is disabled and marked with `deletion_requested_at` and `deletion_scheduled_for`.
+2. Non-essential data is cleared: avatar, projects, interests, special skills, custom data.
+3. **Profile data (name, email, university ID) and thesis-related files (CV, degree report, examination report) are preserved** so that professors can still find and reference thesis records by student name during the retention period.
+4. Notification settings and user groups are deleted.
+5. The authentication guard prevents the user from logging back in (SSO sync is blocked).
+
+The **nightly job** (`DataRetentionService`) checks all soft-deleted accounts. Once all retention periods have expired for a user, it performs the full deletion (Scenario A).
+
+### Preconditions
+
+Deletion is blocked if the user:
+- Is a **research group head** (must transfer leadership first).
+- Has **active (non-terminal) theses** (must complete or drop out first).
+
+### Endpoints
+
+| Method | Path | Auth | Description |
+|--------|------|------|-------------|
+| `GET` | `/v2/user-deletion/me/preview` | Any authenticated | Preview what would happen |
+| `DELETE` | `/v2/user-deletion/me` | Any authenticated | Self-service deletion |
+| `GET` | `/v2/user-deletion/{userId}/preview` | Admin | Preview for specific user |
+| `DELETE` | `/v2/user-deletion/{userId}` | Admin | Admin deletes user |
+
+### Database Changes (Migration 30)
+
+- Added columns to `users`: `anonymized_at`, `deletion_requested_at`, `deletion_scheduled_for`.
+- FK constraints changed to `ON DELETE CASCADE` for user-owned metadata (notification_settings, user_groups, data_exports).
+- FK constraints changed to `ON DELETE SET NULL` for audit references on retained records (thesis_assessments.created_by, thesis_comments.created_by, thesis_feedback.requested_by, thesis_files.uploaded_by, thesis_proposals.created_by, topics.created_by, email_templates.updated_by, research_groups.created_by/updated_by, topic_roles.assigned_by, thesis_roles.assigned_by).
+
## Implementation TODO
Prioritized by urgency and impact on GDPR compliance.
@@ -62,7 +108,7 @@ Prioritized by urgency and impact on GDPR compliance.
### Priority 1 — High (address quickly)
- [x] **Automatic deletion of rejected applications after 1 year**: The privacy statement promises this retention period. Without enforcement, rejected application data accumulates indefinitely, creating a documented discrepancy between the privacy statement and actual behavior.
-- [ ] **Account/data deletion endpoint**: There is currently no way for users to delete their account or data. Add a self-service account deletion feature or at minimum an admin endpoint to fully delete a user's data (Art. 17 right to erasure). Must respect the retention periods defined above (e.g. thesis data cannot be deleted before the 5-year period expires).
+- [x] **Account/data deletion endpoint**: Self-service and admin account deletion (Art. 17 right to erasure). See "Account Deletion Implementation" section below for details.
- [ ] **Configurable application email content**: Add a per-research-group setting to control whether application notification emails include attachments (CV, examination report) and personal details, or only contain the student name, topic, and a link to the application in the system. This addresses a user request. Responding promptly demonstrates good faith.
### Priority 2 — Medium (implement within next months)
@@ -70,7 +116,7 @@ Prioritized by urgency and impact on GDPR compliance.
- [x] **Data export endpoint**: Self-service GDPR data export feature (Art. 15 / Art. 20). Users can request an export from `/data-export`, which is processed overnight and generates a ZIP file containing profile data (JSON), applications, theses, assessments, and uploaded files. Users receive an email notification when ready. Downloads expire after 7 days, rate-limited to one request per 7 days.
- [x] **Automatic disabling of inactive accounts after 1 year of inactivity**: Required to fulfill the retention promise in the privacy statement. Without this, user data is retained indefinitely.
- [x] **Reactivation of disabled accounts on login**: Necessary counterpart to the above — disabled users who log in again should have their account re-enabled automatically.
-- [ ] **Deletion of disabled user accounts after linked data retention periods expire**: Completes the account lifecycle — once all linked thesis/application data has been cleaned up, the account itself should be removed.
+- [x] **Deletion of disabled user accounts after linked data retention periods expire**: Handled by the nightly job (`DataRetentionService.processDeferredDeletions`), which checks soft-deleted accounts and performs full deletion once all retention periods have expired.
### Priority 3 — Low (implement when capacity allows)
diff --git a/keycloak/thesis-management-realm.json b/keycloak/thesis-management-realm.json
index 6cec3e320..a13c4dfa8 100644
--- a/keycloak/thesis-management-realm.json
+++ b/keycloak/thesis-management-realm.json
@@ -577,7 +577,9 @@
"emailVerified": true,
"enabled": true,
"attributes": {
- "matrikelnr": ["03700001"]
+ "matrikelnr": [
+ "03700001"
+ ]
},
"credentials": [
{
@@ -604,7 +606,9 @@
"emailVerified": true,
"enabled": true,
"attributes": {
- "matrikelnr": ["03700002"]
+ "matrikelnr": [
+ "03700002"
+ ]
},
"credentials": [
{
@@ -631,7 +635,9 @@
"emailVerified": true,
"enabled": true,
"attributes": {
- "matrikelnr": ["03700003"]
+ "matrikelnr": [
+ "03700003"
+ ]
},
"credentials": [
{
@@ -660,7 +666,9 @@
"emailVerified": true,
"enabled": true,
"attributes": {
- "matrikelnr": ["03700004"]
+ "matrikelnr": [
+ "03700004"
+ ]
},
"credentials": [
{
@@ -687,7 +695,9 @@
"emailVerified": true,
"enabled": true,
"attributes": {
- "matrikelnr": ["03700005"]
+ "matrikelnr": [
+ "03700005"
+ ]
},
"credentials": [
{
@@ -714,7 +724,9 @@
"emailVerified": true,
"enabled": true,
"attributes": {
- "matrikelnr": ["03700006"]
+ "matrikelnr": [
+ "03700006"
+ ]
},
"credentials": [
{
@@ -743,7 +755,9 @@
"emailVerified": true,
"enabled": true,
"attributes": {
- "matrikelnr": ["03700007"]
+ "matrikelnr": [
+ "03700007"
+ ]
},
"credentials": [
{
@@ -772,7 +786,9 @@
"emailVerified": true,
"enabled": true,
"attributes": {
- "matrikelnr": ["03700008"]
+ "matrikelnr": [
+ "03700008"
+ ]
},
"credentials": [
{
@@ -801,7 +817,9 @@
"emailVerified": true,
"enabled": true,
"attributes": {
- "matrikelnr": ["03700009"]
+ "matrikelnr": [
+ "03700009"
+ ]
},
"credentials": [
{
@@ -821,6 +839,99 @@
"groups": [
"/thesis-students"
]
+ },
+ {
+ "username": "delete_old_thesis",
+ "email": "delete_old_thesis@test.local",
+ "firstName": "OldThesis",
+ "lastName": "Deletable",
+ "emailVerified": true,
+ "enabled": true,
+ "attributes": {
+ "matrikelnr": [
+ "03700011"
+ ]
+ },
+ "credentials": [
+ {
+ "type": "password",
+ "value": "delete_old_thesis",
+ "temporary": false
+ }
+ ],
+ "realmRoles": [
+ "default-roles-thesis-management"
+ ],
+ "clientRoles": {
+ "thesis-management-app": [
+ "student"
+ ]
+ },
+ "groups": [
+ "/thesis-students"
+ ]
+ },
+ {
+ "username": "delete_recent_thesis",
+ "email": "delete_recent_thesis@test.local",
+ "firstName": "RecentThesis",
+ "lastName": "Retainable",
+ "emailVerified": true,
+ "enabled": true,
+ "attributes": {
+ "matrikelnr": [
+ "03700012"
+ ]
+ },
+ "credentials": [
+ {
+ "type": "password",
+ "value": "delete_recent_thesis",
+ "temporary": false
+ }
+ ],
+ "realmRoles": [
+ "default-roles-thesis-management"
+ ],
+ "clientRoles": {
+ "thesis-management-app": [
+ "student"
+ ]
+ },
+ "groups": [
+ "/thesis-students"
+ ]
+ },
+ {
+ "username": "delete_rejected_app",
+ "email": "delete_rejected_app@test.local",
+ "firstName": "RejectedApp",
+ "lastName": "Deletable",
+ "emailVerified": true,
+ "enabled": true,
+ "attributes": {
+ "matrikelnr": [
+ "03700013"
+ ]
+ },
+ "credentials": [
+ {
+ "type": "password",
+ "value": "delete_rejected_app",
+ "temporary": false
+ }
+ ],
+ "realmRoles": [
+ "default-roles-thesis-management"
+ ],
+ "clientRoles": {
+ "thesis-management-app": [
+ "student"
+ ]
+ },
+ "groups": [
+ "/thesis-students"
+ ]
}
],
"scopeMappings": [
@@ -2734,4 +2845,4 @@
"clientPolicies": {
"policies": []
}
-}
\ No newline at end of file
+}
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/repository/ThesisRoleRepository.java b/server/src/main/java/de/tum/cit/aet/thesis/repository/ThesisRoleRepository.java
index af55e4269..19f188aa9 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/repository/ThesisRoleRepository.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/repository/ThesisRoleRepository.java
@@ -3,6 +3,8 @@
import de.tum.cit.aet.thesis.entity.ThesisRole;
import de.tum.cit.aet.thesis.entity.key.ThesisRoleId;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
@@ -13,6 +15,9 @@
public interface ThesisRoleRepository extends JpaRepository {
List deleteByThesisId(UUID thesisId);
+ @Query("SELECT tr FROM ThesisRole tr JOIN FETCH tr.thesis WHERE tr.id.userId = :userId")
+ List findAllByIdUserIdWithThesis(@Param("userId") UUID userId);
+
List findAllByIdUserId(UUID userId);
void deleteAllByIdUserId(UUID userId);
diff --git a/server/src/main/java/de/tum/cit/aet/thesis/service/UserDeletionService.java b/server/src/main/java/de/tum/cit/aet/thesis/service/UserDeletionService.java
index 55644a572..8e7775b78 100644
--- a/server/src/main/java/de/tum/cit/aet/thesis/service/UserDeletionService.java
+++ b/server/src/main/java/de/tum/cit/aet/thesis/service/UserDeletionService.java
@@ -166,8 +166,9 @@ public void processDeferredDeletions() {
private UserDeletionResultDto performFullDeletion(User user) {
UUID userId = user.getId();
- // Delete remaining applications
- applicationRepository.deleteAllByUserId(userId);
+ // Delete remaining applications (use entity-based deletion to keep Hibernate session consistent)
+ List remainingApps = applicationRepository.findAllByUserId(userId);
+ applicationRepository.deleteAll(remainingApps);
// Delete topic roles
topicRoleRepository.deleteAllByIdUserId(userId);
@@ -175,7 +176,13 @@ private UserDeletionResultDto performFullDeletion(User user) {
// Delete thesis roles (should be empty if no retention-blocked data)
thesisRoleRepository.deleteAllByIdUserId(userId);
- // Delete user record (FK cascades handle notification_settings, user_groups, data_exports)
+ // Explicitly delete user-owned entities to avoid Hibernate session conflicts
+ // (UserGroup is EAGER-fetched and causes TransientPropertyValueException otherwise)
+ notificationSettingRepository.deleteAll(user.getNotificationSettings());
+ userGroupRepository.deleteByUserId(userId);
+ user.getGroups().clear();
+ user.getNotificationSettings().clear();
+
userRepository.delete(user);
log.info("Fully deleted user account {}", userId);
@@ -222,13 +229,13 @@ private void deleteAllUserFiles(User user) {
}
private boolean hasActiveTheses(UUID userId) {
- return thesisRoleRepository.findAllByIdUserId(userId).stream()
+ return thesisRoleRepository.findAllByIdUserIdWithThesis(userId).stream()
.anyMatch(role -> !TERMINAL_STATES.contains(role.getThesis().getState()));
}
private List getRetentionBlockedThesisRoles(UUID userId) {
Instant now = Instant.now();
- return thesisRoleRepository.findAllByIdUserId(userId).stream()
+ return thesisRoleRepository.findAllByIdUserIdWithThesis(userId).stream()
.filter(role -> TERMINAL_STATES.contains(role.getThesis().getState()))
.filter(role -> computeRetentionExpiry(role).isAfter(now))
.toList();
@@ -254,7 +261,7 @@ private void deleteNonRetainedApplications(UUID userId) {
List applications = applicationRepository.findAllByUserId(userId);
for (Application app : applications) {
if (app.getState() == ApplicationState.REJECTED || app.getState() == ApplicationState.NOT_ASSESSED) {
- applicationRepository.deleteApplicationById(app.getId());
+ applicationRepository.delete(app);
}
}
}
diff --git a/server/src/main/resources/db/changelog/changes/23_seed_dev_test_data.xml b/server/src/main/resources/db/changelog/changes/23_seed_dev_test_data.xml
index f3712444a..c3abba894 100644
--- a/server/src/main/resources/db/changelog/changes/23_seed_dev_test_data.xml
+++ b/server/src/main/resources/db/changelog/changes/23_seed_dev_test_data.xml
@@ -3,7 +3,7 @@
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
-
+
diff --git a/server/src/main/resources/db/changelog/manual/seed_dev_test_data.sql b/server/src/main/resources/db/changelog/manual/seed_dev_test_data.sql
index 243727e4c..608a19a89 100644
--- a/server/src/main/resources/db/changelog/manual/seed_dev_test_data.sql
+++ b/server/src/main/resources/db/changelog/manual/seed_dev_test_data.sql
@@ -1059,3 +1059,144 @@ Please note that the download link will expire in 7 days. After that, you can re