Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -340,29 +340,32 @@ 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);

referenceRequestService.cancelPendingForWithdrawnApplication(application);
if (application.getState() != ApplicationState.SENT) {
throw new OperationNotAllowedException(
"Application " + applicationId + " cannot be unsubmitted from state " + application.getState()
);
}

Email email = Email.builder()
.to(user)
.language(Language.fromCode(user.getSelectedLanguage()))
.emailType(EmailType.APPLICATION_WITHDRAWN)
.content(application)
.researchGroup(job.getResearchGroup())
.build();
LocalDate endDate = job.getEndDate();
if (endDate != null && endDate.isBefore(LocalDate.now())) {
throw new OperationNotAllowedException("Application " + applicationId + " cannot be unsubmitted after the job deadline");
}

sender.sendAsync(email);
application.setState(ApplicationState.SAVED);
applicationRepository.save(application);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,27 +401,6 @@ public Map<UUID, Set<ReferenceRequest>> getReferencesForApplicationIds(List<UUID
.collect(Collectors.groupingBy(r -> r.getApplication().getApplicationId(), Collectors.toCollection(HashSet::new)));
}

/**
* Cancels every still-pending reference request on a withdrawn application and notifies the referee
* that their recommendation is no longer needed. Only {@code REQUESTED} entries are affected.
* Already submitted, declined or expired requests are left untouched.
*
* @param application the application that was just withdrawn
*/
public void cancelPendingForWithdrawnApplication(Application application) {
List<ReferenceRequest> entries = referenceRequestRepository.findByApplicationApplicationIdOrderByCreatedAtAsc(
application.getApplicationId()
);
for (ReferenceRequest entry : entries) {
if (entry.getStatus() != ReferenceRequestStatus.REQUESTED) {
continue;
}
entry.setStatus(ReferenceRequestStatus.CANCELLED);
referenceRequestRepository.save(entry);
sendCancellationEmail(application, entry);
}
}

/**
* Flips every {@code REQUESTED} entry whose token has already lapsed to {@code EXPIRED}.
*
Expand Down Expand Up @@ -585,44 +564,6 @@ private void sendRefereeEmail(Application application, ReferenceRequest entry, S
emailSender.sendAsync(email);
}

/**
* Notifies a referee that a request was cancelled because the applicant withdrew their application.
*
* @param application the withdrawn application the referee was attached to
* @param entry the reference request that was cancelled
*/
private void sendCancellationEmail(Application application, ReferenceRequest entry) {
Job job = application.getJob();
User refereeStub = new User();
refereeStub.setEmail(entry.getEmail());
refereeStub.setFirstName(entry.getFirstName());
refereeStub.setLastName(entry.getLastName());

ReferenceLetterContextDTO ctx = new ReferenceLetterContextDTO(
entry.getTitle(),
entry.getFirstName(),
entry.getLastName(),
application.getApplicantFirstName(),
application.getApplicantLastName(),
job.getTitle(),
job.getResearchGroup().getName(),
"",
"",
job.getRecommendationType()
);

Email email = Email.builder()
.to(refereeStub)
.language(Language.ENGLISH)
.emailType(EmailType.REFERENCE_LETTER_CANCELLED)
.content(ctx)
.researchGroup(job.getResearchGroup())
.sendAlways(true)
.build();

emailSender.sendAsync(email);
}

Comment thread
az108 marked this conversation as resolved.
/**
* Resolves the latest valid expiry for a freshly issued token:
* Job's end date if set, otherwise defaults to {@value #DEFAULT_VALIDITY_MONTHS} months from now.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
if (this.previewDetailData()) return false;
const app = this.application();
if (!app || (app.referenceLettersRequired ?? 0) <= 0) return false;
if (app.jobEndDate) {

Check warning on line 117 in src/main/webapp/app/application/application-detail-for-applicant/application-detail-for-applicant.component.ts

View workflow job for this annotation

GitHub Actions / Client Quality & Tests

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
const endDate = new Date(app.jobEndDate);
endDate.setHours(23, 59, 59, 999);
if (endDate < new Date()) {
Expand Down Expand Up @@ -145,10 +145,10 @@
*/
submittedReferenceLetters = computed(() =>
this.references()
.filter(reference => !!reference.documentId)

Check warning on line 148 in src/main/webapp/app/application/application-detail-for-applicant/application-detail-for-applicant.component.ts

View workflow job for this annotation

GitHub Actions / Client Quality & Tests

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
.map(reference => ({
documentId: reference.documentId,
refereeName: [reference.firstName, reference.lastName].filter(part => !!part).join(' '),

Check warning on line 151 in src/main/webapp/app/application/application-detail-for-applicant/application-detail-for-applicant.component.ts

View workflow job for this annotation

GitHub Actions / Client Quality & Tests

Unexpected nullable string value in conditional. Please handle the nullish/empty cases explicitly
viewerInput: {
id: reference.documentId as string,
name: `${reference.firstName ?? ''} ${reference.lastName ?? ''}`.trim(),
Expand Down Expand Up @@ -194,8 +194,9 @@
});
}

// Add Withdraw button for SENT/IN_REVIEW states
if (['SENT', 'IN_REVIEW'].includes(app.applicationState)) {
// Unsubmit: 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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
) {
// Unsubmit: 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',
Expand Down
2 changes: 1 addition & 1 deletion src/main/webapp/i18n/de/button.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"submit": "Absenden",
"request": "Anfordern",
"view": "Anzeigen",
"withdraw": "Zurückziehen",
"withdraw": "Einreichung zurücknehmen",
"back": "Zurück",
"previous": "Zurück",
"next": "Weiter",
Expand Down
20 changes: 10 additions & 10 deletions src/main/webapp/i18n/de/global.json
Original file line number Diff line number Diff line change
Expand Up @@ -476,8 +476,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": "Einreichung zurücknehmen?",
"withdrawMessage": "Deine Bewerbung wird zurück in den Entwurf-Status gesetzt. Der Professor sieht sie nicht mehr und du kannst sie vor Ablauf der Frist bearbeiten und erneut einreichen.",
"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!"
},
Expand Down Expand Up @@ -617,12 +617,12 @@
"detail": "Bitte versuche es erneut. Sonst kontaktiere uns."
},
"applicationWithdrawn": {
"summary": "Bewerbung zurückgezogen",
"detail": "Deine Bewerbung wurde erfolgreich zurückgezogen."
"summary": "Bewerbung als Entwurf gespeichert",
"detail": "Deine Bewerbung ist wieder im Entwurf. Bearbeite und reiche sie vor der Frist erneut ein."
},
"errorWithdrawingApplication": {
"summary": "Zurückziehen fehlgeschlagen",
"detail": "Bitte versuche es erneut. Sonst kontaktiere uns."
"summary": "Zurücknahme fehlgeschlagen",
"detail": "Die Bewerbung konnte nicht in den Entwurf zurückgesetzt werden. Möglicherweise ist die Frist abgelaufen, bitte versuche es erneut."
},
"jobIdNotAvailable": {
"summary": "Job-Referenz fehlt",
Expand Down Expand Up @@ -662,12 +662,12 @@
},
"withdraw": {
"success": {
"summary": "Bewerbung zurückgezogen",
"detail": "Deine Bewerbung wurde erfolgreich zurückgezogen."
"summary": "Bewerbung als Entwurf gespeichert",
"detail": "Deine Bewerbung ist wieder im Entwurf. Bearbeite 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."
"summary": "Zurücknahme fehlgeschlagen",
"detail": "Die Bewerbung konnte nicht in den Entwurf zurückgesetzt werden. Möglicherweise ist die Frist abgelaufen."
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/webapp/i18n/en/button.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"submit": "Submit",
"request": "Request",
"view": "View",
"withdraw": "Withdraw",
"withdraw": "Unsubmit",
"back": "Back",
"previous": "Previous",
"next": "Next",
Expand Down
20 changes: 10 additions & 10 deletions src/main/webapp/i18n/en/global.json
Original file line number Diff line number Diff line change
Expand Up @@ -476,8 +476,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": "Unsubmit application?",
"withdrawMessage": "This will move your application back to draft. The professor will no longer see it, and you can edit and resubmit it before the job's deadline.",
"deleteHeader": "Confirm Delete Operation",
"deleteMessage": "Are you sure you want to permanently delete this draft? This action can NOT be undone!"
},
Expand Down Expand Up @@ -617,12 +617,12 @@
"detail": "Please try again. Contact us if it keeps failing."
},
"applicationWithdrawn": {
"summary": "Application withdrawn",
"detail": "Your application was withdrawn successfully."
"summary": "Application moved back to draft",
"detail": "Your application is back in draft. Edit and resubmit it before the deadline."
},
"errorWithdrawingApplication": {
"summary": "Withdrawal failed",
"detail": "Please try again. Contact us if it keeps failing."
"summary": "Couldn't move back to draft",
"detail": "The application could not be moved back to draft. The deadline may have passed, or please try again."
},
"jobIdNotAvailable": {
"summary": "Job reference missing",
Expand Down Expand Up @@ -662,12 +662,12 @@
},
"withdraw": {
"success": {
"summary": "Application withdrawn",
"detail": "Your application was withdrawn successfully."
"summary": "Application moved back to draft",
"detail": "Your application is back in draft. Edit and resubmit it before the deadline."
},
"error": {
"summary": "Withdrawal failed",
"detail": "The application could not be withdrawn. Please try again."
"summary": "Couldn't move back to draft",
"detail": "The application could not be moved back to draft. The deadline may have passed."
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,21 +453,49 @@ 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);

api
.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -495,72 +495,4 @@ void shouldTransitionReferencesToRequestedAndDispatchEmailsWhenApplicationIsSubm
verify(mockSender, times(2)).sendAsync(any());
}
}

@Nested
class WithdrawCancellation {

private static final String WITHDRAW_URL = "/api/applications/withdraw/%s";

@Test
void shouldCancelPendingReferencesAndNotifyRefereesWhenApplicationIsWithdrawn() {
Application sentApplication = ApplicationTestData.saved(
applicationRepository,
jobWithReferences,
applicant,
ApplicationState.SENT
);
ReferenceRequest pending = ReferenceRequestTestData.newReferenceRequest(sentApplication, "pending@example.com");
pending.setTokenHash("pending-hash");
pending = referenceRequestRepository.save(pending);
ReferenceRequest submitted = ReferenceRequestTestData.newReferenceRequest(
sentApplication,
"Prof.",
"Alan",
"Turing",
"submitted@example.com",
ReferenceRequestStatus.SUBMITTED
);
submitted = referenceRequestRepository.save(submitted);

api
.with(JwtPostProcessors.jwtUser(applicant.getUserId(), "ROLE_APPLICANT"))
.putAndRead(String.format(WITHDRAW_URL, sentApplication.getApplicationId()), null, Void.class, 200);

assertThat(referenceRequestRepository.findById(pending.getReferenceRequestId()).orElseThrow().getStatus()).isEqualTo(
ReferenceRequestStatus.CANCELLED
);
assertThat(referenceRequestRepository.findById(submitted.getReferenceRequestId()).orElseThrow().getStatus()).isEqualTo(
ReferenceRequestStatus.SUBMITTED
);
verify(mockSender, times(1)).sendAsync(any());
}

@Test
void shouldNotSendCancellationEmailsWhenNoReferenceIsPending() {
Application sentApplication = ApplicationTestData.saved(
applicationRepository,
jobWithReferences,
applicant,
ApplicationState.SENT
);
ReferenceRequest declined = ReferenceRequestTestData.newReferenceRequest(
sentApplication,
"Prof.",
"Grace",
"Hopper",
"declined@example.com",
ReferenceRequestStatus.DECLINED
);
referenceRequestRepository.save(declined);

api
.with(JwtPostProcessors.jwtUser(applicant.getUserId(), "ROLE_APPLICANT"))
.putAndRead(String.format(WITHDRAW_URL, sentApplication.getApplicationId()), null, Void.class, 200);

assertThat(referenceRequestRepository.findById(declined.getReferenceRequestId()).orElseThrow().getStatus()).isEqualTo(
ReferenceRequestStatus.DECLINED
);
verify(mockSender, never()).sendAsync(any());
}
}
}
Loading