Skip to content
Open
Show file tree
Hide file tree
Changes from all 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,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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
* <p>
* 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
*/
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<jhi-confirm-dialog
class="hidden"
header="entity.applicationSteps.confirmDialog.header"
message="entity.applicationSteps.confirmDialog.message"
[message]="sendDialogMessage()"
label="button.submit"
[severity]="sendButtonSeverity"
[confirmIcon]="sendButtonIcon"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { ApplicationResourceApi } from 'app/generated/api/application-resource-a
import { UpdateApplicationDTO } from 'app/generated/model/update-application-dto';
import { AuthOrchestratorService } from 'app/core/auth/auth-orchestrator.service';
import { ExtractedCertificateDataDTO } from 'app/generated/model/extracted-certificate-data-dto';
import { ReferenceRequestDTO } from 'app/generated/model/reference-request-dto';
import { ReferenceRequestDTO, ReferenceRequestDTOStatusEnum } from 'app/generated/model/reference-request-dto';
import { RecommendationType } from 'app/generated/model/recommendation-type';
import { CheckboxComponent } from 'app/shared/components/atoms/checkbox/checkbox.component';

Expand Down Expand Up @@ -144,6 +144,23 @@ export default class ApplicationCreationFormComponent {
educationDataValid = signal<boolean>(false);
applicationDetailsDataValid = signal<boolean>(false);
references = signal<ReferenceRequestDTO[]>([]);

/**
* 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<boolean>(() =>
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<string>(() =>
this.hasRecommendersToReinvite()
? 'entity.applicationSteps.confirmDialog.messageRecommendersReinvited'
: 'entity.applicationSteps.confirmDialog.message',
);

referenceLettersConfidential = signal<boolean>(true);
referenceLettersRequired = signal<number>(0);
referenceLettersEnabled = computed(() => this.referenceLettersRequired() > 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
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)
) {
// 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',
Expand Down
15 changes: 8 additions & 7 deletions src/main/webapp/i18n/de/global.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down Expand Up @@ -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!"
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."
}
}
}
Expand Down
15 changes: 8 additions & 7 deletions src/main/webapp/i18n/en/global.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down Expand Up @@ -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!"
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."
}
}
}
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 @@ -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;

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading