diff --git a/src/main/java/de/tum/cit/aet/application/service/ApplicationService.java b/src/main/java/de/tum/cit/aet/application/service/ApplicationService.java
index e5d7a43a22..4795738a57 100644
--- a/src/main/java/de/tum/cit/aet/application/service/ApplicationService.java
+++ b/src/main/java/de/tum/cit/aet/application/service/ApplicationService.java
@@ -340,29 +340,34 @@ private void confirmApplicationToProfessor(Application application) {
}
/**
- * Withdraws an application by setting its state to WITHDRAWN.
+ * Reverts a submitted application back to {@link ApplicationState#SAVED} so
+ * the applicant can edit and resubmit it. Only applications currently in
+ * {@link ApplicationState#SENT} can be unsubmitted, and only while the job's
+ * deadline has not yet passed.
*
- * @param applicationId the UUID of the application to withdraw
+ * @param applicationId the UUID of the application to unsubmit
+ * @throws OperationNotAllowedException if the application is not in SENT state
+ * or the job deadline has already passed
*/
public void withdrawApplication(UUID applicationId) {
Application application = assertCanManageApplication(applicationId);
- User user = application.getApplicant().getUser();
Job job = application.getJob();
- application.setState(ApplicationState.WITHDRAWN);
- application = applicationRepository.save(application);
+ if (application.getState() != ApplicationState.SENT) {
+ throw new OperationNotAllowedException(
+ "Application " + applicationId + " cannot be unsubmitted from state " + application.getState()
+ );
+ }
- referenceRequestService.cancelPendingForWithdrawnApplication(application);
+ LocalDate endDate = job.getEndDate();
+ if (endDate != null && endDate.isBefore(LocalDate.now())) {
+ throw new OperationNotAllowedException("Application " + applicationId + " cannot be unsubmitted after the job deadline");
+ }
- Email email = Email.builder()
- .to(user)
- .language(Language.fromCode(user.getSelectedLanguage()))
- .emailType(EmailType.APPLICATION_WITHDRAWN)
- .content(application)
- .researchGroup(job.getResearchGroup())
- .build();
+ application.setState(ApplicationState.SAVED);
+ application = applicationRepository.save(application);
- sender.sendAsync(email);
+ referenceRequestService.cancelPendingForWithdrawnApplication(application);
}
/**
diff --git a/src/main/java/de/tum/cit/aet/reference/service/ReferenceRequestService.java b/src/main/java/de/tum/cit/aet/reference/service/ReferenceRequestService.java
index 4ef999f526..48e4a01fab 100644
--- a/src/main/java/de/tum/cit/aet/reference/service/ReferenceRequestService.java
+++ b/src/main/java/de/tum/cit/aet/reference/service/ReferenceRequestService.java
@@ -228,6 +228,11 @@ public void removeFromApplication(UUID applicationId, UUID referenceId) {
* Generates and persists a fresh token for each pending entry on the application and dispatches
* the invitation email. Must be called from a transactional context — the caller's transaction
* keeps {@code application.job} / {@code application.job.researchGroup} attached for lazy access.
+ *
+ * Referees never invited yet are invited for the first time. Referees whose request was cancelled
+ * by an earlier withdrawal are invited again with a fresh token, so the link they were sent before
+ * the withdrawal stays dead. Referees who already submitted, declined or let their link expire are
+ * left alone, so resubmitting never asks them a second time.
*
* @param application the application whose referees should be notified
*/
@@ -239,7 +244,9 @@ public void dispatchInvitations(Application application) {
application.getApplicationId()
);
for (ReferenceRequest entry : entries) {
- if (entry.getStatus() != ReferenceRequestStatus.ADDED || entry.getTokenHash() != null) {
+ boolean neverInvited = entry.getStatus() == ReferenceRequestStatus.ADDED && entry.getTokenHash() == null;
+ boolean cancelledByWithdrawal = entry.getStatus() == ReferenceRequestStatus.CANCELLED;
+ if (!neverInvited && !cancelledByWithdrawal) {
continue;
}
issueInvitation(application, entry);
diff --git a/src/main/webapp/app/application/application-creation/application-creation-form/application-creation-form.component.html b/src/main/webapp/app/application/application-creation/application-creation-form/application-creation-form.component.html
index 3ed0d02189..0b97e7915b 100644
--- a/src/main/webapp/app/application/application-creation/application-creation-form/application-creation-form.component.html
+++ b/src/main/webapp/app/application/application-creation/application-creation-form/application-creation-form.component.html
@@ -1,7 +1,7 @@
(false);
applicationDetailsDataValid = signal(false);
references = signal([]);
+
+ /**
+ * True when at least one referee would be invited again by submitting. The server re-invites
+ * exactly the entries a withdrawal cancelled; entries never invited yet are first-time invites
+ * and submitted, declined or expired ones are left alone.
+ */
+ hasRecommendersToReinvite = computed(() =>
+ this.references().some(reference => reference.status === ReferenceRequestDTOStatusEnum.Cancelled),
+ );
+
+ /** i18n key of the send confirmation message, warning about the re-invitation when one is due. */
+ sendDialogMessage = computed(() =>
+ this.hasRecommendersToReinvite()
+ ? 'entity.applicationSteps.confirmDialog.messageRecommendersReinvited'
+ : 'entity.applicationSteps.confirmDialog.message',
+ );
+
referenceLettersConfidential = signal(true);
referenceLettersRequired = signal(0);
referenceLettersEnabled = computed(() => this.referenceLettersRequired() > 0);
diff --git a/src/main/webapp/app/application/application-detail-for-applicant/application-detail-for-applicant.component.ts b/src/main/webapp/app/application/application-detail-for-applicant/application-detail-for-applicant.component.ts
index be7128645f..62987d6a09 100644
--- a/src/main/webapp/app/application/application-detail-for-applicant/application-detail-for-applicant.component.ts
+++ b/src/main/webapp/app/application/application-detail-for-applicant/application-detail-for-applicant.component.ts
@@ -195,8 +195,9 @@ export default class ApplicationDetailForApplicantComponent {
});
}
- // Add Withdraw button for SENT/IN_REVIEW states
- if (['SENT', 'IN_REVIEW'].includes(app.applicationState)) {
+ // Withdraw: only while the application is still in SENT (i.e. not yet
+ // picked up for review). Server also enforces the job-deadline guard.
+ if (app.applicationState === 'SENT') {
items.push({
label: 'button.withdraw',
icon: 'withdraw',
diff --git a/src/main/webapp/app/application/application-overview-for-applicant/application-overview-for-applicant.component.ts b/src/main/webapp/app/application/application-overview-for-applicant/application-overview-for-applicant.component.ts
index b78b51bf87..4437cc491d 100644
--- a/src/main/webapp/app/application/application-overview-for-applicant/application-overview-for-applicant.component.ts
+++ b/src/main/webapp/app/application/application-overview-for-applicant/application-overview-for-applicant.component.ts
@@ -141,15 +141,9 @@ export default class ApplicationOverviewForApplicantComponent {
});
}
- // Withdraw action - for SENT or IN_REVIEW applications
- if (
- (
- [
- ApplicationOverviewDTOApplicationStateEnum.Sent,
- ApplicationOverviewDTOApplicationStateEnum.InReview,
- ] as ApplicationOverviewDTOApplicationStateEnum[]
- ).includes(application.applicationState as ApplicationOverviewDTOApplicationStateEnum)
- ) {
+ // Withdraw: only while the application is still in SENT (i.e. not yet
+ // picked up for review). Server also enforces the job-deadline guard.
+ if (application.applicationState === ApplicationOverviewDTOApplicationStateEnum.Sent) {
items.push({
label: 'button.withdraw',
icon: 'withdraw',
diff --git a/src/main/webapp/i18n/de/global.json b/src/main/webapp/i18n/de/global.json
index 1b77c1cd74..9ea9e3f70b 100644
--- a/src/main/webapp/i18n/de/global.json
+++ b/src/main/webapp/i18n/de/global.json
@@ -277,7 +277,8 @@
"summary": "Zusammenfassung",
"confirmDialog": {
"header": "Bewerbung absenden",
- "message": "Bist du sicher, dass du deine Bewerbung absenden möchtest? Eine nachträgliche Bearbeitung ist dann NICHT mehr möglich."
+ "message": "Bist du sicher, dass du deine Bewerbung absenden möchtest? Du kannst sie weiterhin zurückziehen, um Änderungen vorzunehmen, solange sie noch nicht geprüft wurde.",
+ "messageRecommendersReinvited": "Bist du sicher, dass du deine Bewerbung absenden möchtest? Deine Referenzpersonen erhalten eine neue Anfrage per E-Mail, da die vor dem Zurückziehen versendeten Links nicht mehr funktionieren."
},
"status": {
"SAVING": "Änderungen werden gespeichert...",
@@ -476,8 +477,8 @@
"created": "Erstellt"
},
"dialogs": {
- "withdrawHeader": "Zurückziehen bestätigen",
- "withdrawMessage": "Bist du sicher, dass du diese Bewerbung zurückziehen möchtest? Sobald sie zurückgezogen wurde, ist sie für den Professor nicht mehr sichtbar. Diese Aktion kann NICHT rückgängig gemacht werden!",
+ "withdrawHeader": "Bewerbung zurückziehen?",
+ "withdrawMessage": "Beim Zurückziehen wird deine Bewerbung wieder ein Entwurf und ist für die Professoren nicht mehr sichtbar. Bereits versendete Links für Empfehlungsschreiben funktionieren dann nicht mehr. Wenn du die Bewerbung erneut einreichst, erhalten deine Referenzpersonen eine neue Anfrage.",
"deleteHeader": "Löschvorgang bestätigen",
"deleteMessage": "Bist du sicher, dass du diesen Entwurf dauerhaft löschen möchtest? Diese Aktion kann NICHT rückgängig gemacht werden!"
},
@@ -618,11 +619,11 @@
},
"applicationWithdrawn": {
"summary": "Bewerbung zurückgezogen",
- "detail": "Deine Bewerbung wurde erfolgreich zurückgezogen."
+ "detail": "Deine Bewerbung ist wieder ein Entwurf. Bearbeite sie und reiche sie vor der Frist erneut ein."
},
"errorWithdrawingApplication": {
"summary": "Zurückziehen fehlgeschlagen",
- "detail": "Bitte versuche es erneut. Sonst kontaktiere uns."
+ "detail": "Die Bewerbung konnte nicht zurückgezogen werden. Möglicherweise ist die Frist abgelaufen, bitte versuche es erneut."
},
"jobIdNotAvailable": {
"summary": "Job-Referenz fehlt",
@@ -663,11 +664,11 @@
"withdraw": {
"success": {
"summary": "Bewerbung zurückgezogen",
- "detail": "Deine Bewerbung wurde erfolgreich zurückgezogen."
+ "detail": "Deine Bewerbung ist wieder ein Entwurf. Bearbeite sie und reiche sie vor der Frist erneut ein."
},
"error": {
"summary": "Zurückziehen fehlgeschlagen",
- "detail": "Die Bewerbung konnte nicht zurückgezogen werden. Bitte versuche es erneut."
+ "detail": "Die Bewerbung konnte nicht zurückgezogen werden. Möglicherweise ist die Frist abgelaufen."
}
}
}
diff --git a/src/main/webapp/i18n/en/global.json b/src/main/webapp/i18n/en/global.json
index eaf157c8e9..493d344663 100644
--- a/src/main/webapp/i18n/en/global.json
+++ b/src/main/webapp/i18n/en/global.json
@@ -277,7 +277,8 @@
"summary": "Summary",
"confirmDialog": {
"header": "Submit Application",
- "message": "Are you sure you want to submit your application? Once published, further edits will NOT be possible."
+ "message": "Are you sure you want to submit your application? You can still withdraw it to make changes, as long as it has not been reviewed yet.",
+ "messageRecommendersReinvited": "Are you sure you want to submit your application? Your recommenders will receive a new request by email, because the links they were sent before you withdrew no longer work."
},
"status": {
"SAVING": "Saving changes...",
@@ -476,8 +477,8 @@
"created": "Created"
},
"dialogs": {
- "withdrawHeader": "Confirm Withdrawal",
- "withdrawMessage": "Are you sure you want to withdraw this application? Once withdrawn, it will no longer be visible to the professor. This action can NOT be undone!",
+ "withdrawHeader": "Withdraw application?",
+ "withdrawMessage": "Withdrawing puts your application back into draft, so the professor no longer sees it. Any recommendation links already emailed to your recommenders stop working. When you submit the application again, your recommenders receive a new request.",
"deleteHeader": "Confirm Delete Operation",
"deleteMessage": "Are you sure you want to permanently delete this draft? This action can NOT be undone!"
},
@@ -618,11 +619,11 @@
},
"applicationWithdrawn": {
"summary": "Application withdrawn",
- "detail": "Your application was withdrawn successfully."
+ "detail": "Your application is back in draft. Edit it and submit it again before the deadline."
},
"errorWithdrawingApplication": {
"summary": "Withdrawal failed",
- "detail": "Please try again. Contact us if it keeps failing."
+ "detail": "The application could not be withdrawn. The deadline may have passed, or please try again."
},
"jobIdNotAvailable": {
"summary": "Job reference missing",
@@ -663,11 +664,11 @@
"withdraw": {
"success": {
"summary": "Application withdrawn",
- "detail": "Your application was withdrawn successfully."
+ "detail": "Your application is back in draft. Edit it and submit it again before the deadline."
},
"error": {
"summary": "Withdrawal failed",
- "detail": "The application could not be withdrawn. Please try again."
+ "detail": "The application could not be withdrawn. The deadline may have passed."
}
}
}
diff --git a/src/test/java/de/tum/cit/aet/application/web/rest/ApplicationResourceTest.java b/src/test/java/de/tum/cit/aet/application/web/rest/ApplicationResourceTest.java
index ac9fd3454e..9682997b51 100644
--- a/src/test/java/de/tum/cit/aet/application/web/rest/ApplicationResourceTest.java
+++ b/src/test/java/de/tum/cit/aet/application/web/rest/ApplicationResourceTest.java
@@ -453,12 +453,12 @@ void deleteApplicationWithoutAuthReturnsForbidden() {
}
}
- // ===== WITHDRAW APPLICATION =====
+ // ===== UNSUBMIT APPLICATION =====
@Nested
class WithdrawApplicationTests {
@Test
- void withdrawApplicationMarksAsWithdrawn() {
+ void withdrawApplicationRevertsToDraft() {
Application application = ApplicationTestData.savedSent(applicationRepository, publishedJob, applicant);
assertThat(application.getState()).isEqualTo(ApplicationState.SENT);
@@ -466,8 +466,36 @@ void withdrawApplicationMarksAsWithdrawn() {
.with(JwtPostProcessors.jwtUser(applicant.getUserId(), "ROLE_APPLICANT"))
.putAndRead("/api/applications/withdraw/" + application.getApplicationId(), null, Void.class, 200);
- Application withdrawn = applicationRepository.findById(application.getApplicationId()).orElseThrow();
- assertThat(withdrawn.getState()).isEqualTo(ApplicationState.WITHDRAWN);
+ Application reverted = applicationRepository.findById(application.getApplicationId()).orElseThrow();
+ assertThat(reverted.getState()).isEqualTo(ApplicationState.SAVED);
+ }
+
+ @Test
+ void withdrawApplicationFailsAfterDeadline() {
+ publishedJob.setEndDate(LocalDate.now().minusDays(1));
+ jobRepository.saveAndFlush(publishedJob);
+ Application application = ApplicationTestData.savedSent(applicationRepository, publishedJob, applicant);
+
+ api
+ .with(JwtPostProcessors.jwtUser(applicant.getUserId(), "ROLE_APPLICANT"))
+ .putAndRead("/api/applications/withdraw/" + application.getApplicationId(), null, Void.class, 400);
+
+ Application unchanged = applicationRepository.findById(application.getApplicationId()).orElseThrow();
+ assertThat(unchanged.getState()).isEqualTo(ApplicationState.SENT);
+ }
+
+ @Test
+ void withdrawApplicationFailsWhenNotInSentState() {
+ Application application = ApplicationTestData.savedSent(applicationRepository, publishedJob, applicant);
+ application.setState(ApplicationState.IN_REVIEW);
+ applicationRepository.saveAndFlush(application);
+
+ api
+ .with(JwtPostProcessors.jwtUser(applicant.getUserId(), "ROLE_APPLICANT"))
+ .putAndRead("/api/applications/withdraw/" + application.getApplicationId(), null, Void.class, 400);
+
+ Application unchanged = applicationRepository.findById(application.getApplicationId()).orElseThrow();
+ assertThat(unchanged.getState()).isEqualTo(ApplicationState.IN_REVIEW);
}
@Test
diff --git a/src/test/java/de/tum/cit/aet/reference/ReferenceRequestResourceTest.java b/src/test/java/de/tum/cit/aet/reference/ReferenceRequestResourceTest.java
index 6ad9d1c28c..254df4aeba 100644
--- a/src/test/java/de/tum/cit/aet/reference/ReferenceRequestResourceTest.java
+++ b/src/test/java/de/tum/cit/aet/reference/ReferenceRequestResourceTest.java
@@ -4,6 +4,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -494,6 +495,70 @@ void shouldTransitionReferencesToRequestedAndDispatchEmailsWhenApplicationIsSubm
});
verify(mockSender, times(2)).sendAsync(any());
}
+
+ @Test
+ void shouldInviteCancelledRefereesAgainWithAFreshTokenWhenApplicationIsResubmitted() {
+ saveAddedReference("referee@example.com");
+ submitApplication();
+
+ ReferenceRequest invited = referenceRequestRepository
+ .findByApplicationApplicationIdOrderByCreatedAtAsc(savedApplication.getApplicationId())
+ .getFirst();
+ String firstToken = invited.getTokenHash();
+
+ api
+ .with(JwtPostProcessors.jwtUser(applicant.getUserId(), "ROLE_APPLICANT"))
+ .putAndRead("/api/applications/withdraw/" + savedApplication.getApplicationId(), null, Void.class, 200);
+
+ assertThat(
+ referenceRequestRepository
+ .findByApplicationApplicationIdOrderByCreatedAtAsc(savedApplication.getApplicationId())
+ .getFirst()
+ .getStatus()
+ ).isEqualTo(ReferenceRequestStatus.CANCELLED);
+
+ reset(mockSender);
+ submitApplication();
+
+ ReferenceRequest reinvited = referenceRequestRepository
+ .findByApplicationApplicationIdOrderByCreatedAtAsc(savedApplication.getApplicationId())
+ .getFirst();
+ assertThat(reinvited.getStatus()).as("status after resubmit").isEqualTo(ReferenceRequestStatus.REQUESTED);
+ assertThat(reinvited.getTokenHash()).as("token after resubmit").isNotBlank().isNotEqualTo(firstToken);
+ verify(mockSender, times(1)).sendAsync(any());
+ }
+
+ @Test
+ void shouldNotInviteDeclinedOrSubmittedRefereesAgainWhenApplicationIsResubmitted() {
+ ReferenceRequest declined = ReferenceRequestTestData.newReferenceRequest(
+ savedApplication,
+ "Prof.",
+ "Grace",
+ "Hopper",
+ "declined@example.com",
+ ReferenceRequestStatus.DECLINED
+ );
+ referenceRequestRepository.save(declined);
+ ReferenceRequest submitted = ReferenceRequestTestData.newReferenceRequest(
+ savedApplication,
+ "Prof.",
+ "Alan",
+ "Turing",
+ "submitted@example.com",
+ ReferenceRequestStatus.SUBMITTED
+ );
+ referenceRequestRepository.save(submitted);
+
+ submitApplication();
+
+ assertThat(referenceRequestRepository.findById(declined.getReferenceRequestId()).orElseThrow().getStatus()).isEqualTo(
+ ReferenceRequestStatus.DECLINED
+ );
+ assertThat(referenceRequestRepository.findById(submitted.getReferenceRequestId()).orElseThrow().getStatus()).isEqualTo(
+ ReferenceRequestStatus.SUBMITTED
+ );
+ verify(mockSender, never()).sendAsync(any());
+ }
}
@Nested
diff --git a/src/test/webapp/app/application/application-creation/application-creation-form/application-creation-form.component.spec.ts b/src/test/webapp/app/application/application-creation/application-creation-form/application-creation-form.component.spec.ts
index f5cb4bea31..df5ef6667e 100644
--- a/src/test/webapp/app/application/application-creation/application-creation-form/application-creation-form.component.spec.ts
+++ b/src/test/webapp/app/application/application-creation/application-creation-form/application-creation-form.component.spec.ts
@@ -12,6 +12,7 @@ import { HttpResponse } from '@angular/common/http';
import { ApplicationDetailDTOApplicationStateEnum } from 'app/generated/model/application-detail-dto';
import { UpdateApplicationDTO } from 'app/generated/model/update-application-dto';
import { ApplicationCreationPage1Data } from 'app/application/application-creation/application-creation-page1/application-creation-page1.component';
+import { ReferenceRequestDTOStatusEnum } from 'app/generated/model/reference-request-dto';
import { ProgressStepperComponent } from 'app/shared/components/molecules/progress-stepper/progress-stepper.component';
import { AccountServiceMock, createAccountServiceMock, provideAccountServiceMock } from 'util/account.service.mock';
import { createRouterMock, provideRouterMock, RouterMock } from 'util/router.mock';
@@ -695,6 +696,38 @@ describe('ApplicationForm', () => {
});
});
+ describe('sendDialogMessage computed property', () => {
+ it('should use the plain confirmation when no reference request was cancelled', () => {
+ comp.references.set([
+ { referenceRequestId: 'ref-1', status: ReferenceRequestDTOStatusEnum.Added },
+ { referenceRequestId: 'ref-2', status: ReferenceRequestDTOStatusEnum.Submitted },
+ { referenceRequestId: 'ref-3', status: ReferenceRequestDTOStatusEnum.Declined },
+ { referenceRequestId: 'ref-4', status: ReferenceRequestDTOStatusEnum.Expired },
+ { referenceRequestId: 'ref-5', status: ReferenceRequestDTOStatusEnum.Requested },
+ ]);
+
+ expect(comp.hasRecommendersToReinvite()).toBe(false);
+ expect(comp.sendDialogMessage()).toBe('entity.applicationSteps.confirmDialog.message');
+ });
+
+ it('should warn about re-invitation when a reference request was cancelled by a withdrawal', () => {
+ comp.references.set([
+ { referenceRequestId: 'ref-1', status: ReferenceRequestDTOStatusEnum.Submitted },
+ { referenceRequestId: 'ref-2', status: ReferenceRequestDTOStatusEnum.Cancelled },
+ ]);
+
+ expect(comp.hasRecommendersToReinvite()).toBe(true);
+ expect(comp.sendDialogMessage()).toBe('entity.applicationSteps.confirmDialog.messageRecommendersReinvited');
+ });
+
+ it('should use the plain confirmation when the application has no recommenders at all', () => {
+ comp.references.set([]);
+
+ expect(comp.hasRecommendersToReinvite()).toBe(false);
+ expect(comp.sendDialogMessage()).toBe('entity.applicationSteps.confirmDialog.message');
+ });
+ });
+
describe('allPagesValid computed property', () => {
it.each([
[false, true, true, false],