Enhance staff application flow with email verification checks, refined error handling, and improved user feedback in frontend and backend.

This commit is contained in:
akastijn 2025-09-27 20:00:44 +02:00
parent cdbf862ecf
commit 311d77fcb2
5 changed files with 69 additions and 60 deletions

View File

@ -38,45 +38,37 @@ public class ApplicationController implements ApplicationsApi {
public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) { public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) {
UUID userUuid = AuthenticatedUuid.getAuthenticatedUserUuid(); UUID userUuid = AuthenticatedUuid.getAuthenticatedUserUuid();
String email = staffApplicationDto.getEmail() == null ? null : staffApplicationDto.getEmail().toLowerCase();
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, email);
if (optionalEmail.isEmpty() || !optionalEmail.get().verified()) {
log.warn("User {} attempted to submit an application without a verified email {}", userUuid, email);
return ResponseEntity.badRequest().build();
}
// Map and persist application
StaffApplication application = staffApplicationDataMapper.map(userUuid, staffApplicationDto); StaffApplication application = staffApplicationDataMapper.map(userUuid, staffApplicationDto);
saveApplication(application); saveApplication(application);
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, application.email()); try {
boolean verified = optionalEmail.map(EmailVerification::verified).orElse(false); if (!staffApplicationMail.sendApplicationEmail(application)) {
log.warn("Failed to send staff application email for {}", application.id());
boolean success = true; return ResponseEntity.internalServerError().build();
if (verified) {
// Send mail first; only if sent, send to Discord, then mark as sent
boolean mailSent = false;
try {
mailSent = staffApplicationMail.sendApplicationEmail(application);
} catch (Exception e) {
log.error("Error while sending staff application email for {}", application.id(), e);
success = false;
} }
} catch (Exception e) {
if (mailSent) { log.error("Error while sending staff application email for {}", application.id(), e);
try {
staffApplicationDiscord.sendApplicationToDiscord(application);
} catch (Exception e) {
log.error("Failed to send staff application {} to Discord", application.id(), e);
success = false;
}
} else {
success = false;
}
if (success) {
markAsSent(application.id());
}
}
if (verified && !success) {
return ResponseEntity.internalServerError().build(); return ResponseEntity.internalServerError().build();
} }
FormResponseDto response = buildResponse(application, verified); try {
return ResponseEntity.status(201).body(response); staffApplicationDiscord.sendApplicationToDiscord(application);
} catch (Exception e) {
log.error("Failed to send staff application {} to Discord", application.id(), e);
return ResponseEntity.internalServerError().build();
}
markAsSent(application.id());
FormResponseDto response = buildResponse(application);
return ResponseEntity.status(200).body(response);
} }
private void saveApplication(StaffApplication application) { private void saveApplication(StaffApplication application) {
@ -110,10 +102,8 @@ public class ApplicationController implements ApplicationsApi {
.runQuery(sqlSession -> sqlSession.getMapper(StaffApplicationMapper.class).markAsSent(applicationId)); .runQuery(sqlSession -> sqlSession.getMapper(StaffApplicationMapper.class).markAsSent(applicationId));
} }
private FormResponseDto buildResponse(StaffApplication application, boolean verified) { private FormResponseDto buildResponse(StaffApplication application) {
String message = verified String message = "Your staff application has been submitted. You will be notified when it has been reviewed.";
? "Your staff application has been submitted. You will be notified when it has been reviewed."
: "Application created. Please verify your email to complete submission.";
return new FormResponseDto( return new FormResponseDto(
application.id().toString(), application.id().toString(),
message, message,

View File

@ -67,7 +67,14 @@
<div class="valid-email"> <div class="valid-email">
<ng-container matSuffix> <ng-container matSuffix>
<mat-icon>check</mat-icon> <mat-icon>check</mat-icon>
<span>You have validated your email previously, and can continue to the next page!</span> <span>You have validated your email previously.</span>
</ng-container>
</div>
} @else {
<div class="invalid-email">
<ng-container matSuffix>
<mat-icon>close</mat-icon>
<span>You have not used this email address before. Before going to the next page you will be asked to verify it.</span>
</ng-container> </ng-container>
</div> </div>
} }
@ -107,7 +114,8 @@
<!-- PC Requirements --> <!-- PC Requirements -->
<div class="checkbox-field"> <div class="checkbox-field">
<mat-checkbox formControlName="meetsRequirements"> <mat-checkbox formControlName="meetsRequirements">
I confirm that I meet the PC requirements (able to record video at 30fps 720p or higher, and able to talk in voice chat) I confirm that I meet the PC requirements (able to record video at 30fps 720p or higher, and able
to talk in voice chat)
</mat-checkbox> </mat-checkbox>
@if (form.controls.meetsRequirements.invalid && form.controls.meetsRequirements.touched) { @if (form.controls.meetsRequirements.invalid && form.controls.meetsRequirements.touched) {
<mat-error class="checkbox-error"> <mat-error class="checkbox-error">
@ -172,7 +180,8 @@
<label class="field-label">Available days for moderating:</label> <label class="field-label">Available days for moderating:</label>
<div class="days-container"> <div class="days-container">
@for (day of availableDays; track day) { @for (day of availableDays; track day) {
<div class="day-chip" [class.selected]="form.controls.availableDays.value.includes(day)" (click)="toggleDay(day)"> <div class="day-chip" [class.selected]="form.controls.availableDays.value.includes(day)"
(click)="toggleDay(day)">
{{ day }} {{ day }}
</div> </div>
} }
@ -188,9 +197,9 @@
<mat-form-field appearance="fill" style="width: 100%;"> <mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Available times (Your timezone: {{ userTimezone }})</mat-label> <mat-label>Available times (Your timezone: {{ userTimezone }})</mat-label>
<textarea matInput <textarea matInput
formControlName="availableTimes" formControlName="availableTimes"
placeholder="e.g., 6PM-10PM weekdays, 2PM-8PM weekends" placeholder="e.g., 6PM-10PM weekdays, 2PM-8PM weekends"
rows="2"></textarea> rows="2"></textarea>
@if (form.controls.availableTimes.invalid && form.controls.availableTimes.touched) { @if (form.controls.availableTimes.invalid && form.controls.availableTimes.touched) {
<mat-error> <mat-error>
Available times are required Available times are required
@ -215,9 +224,9 @@
<mat-form-field appearance="fill" style="width: 100%;"> <mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Previous experience (here or in other relevant places)</mat-label> <mat-label>Previous experience (here or in other relevant places)</mat-label>
<textarea matInput <textarea matInput
formControlName="previousExperience" formControlName="previousExperience"
placeholder="Describe your previous experience" placeholder="Describe your previous experience"
rows="4"></textarea> rows="4"></textarea>
@if (form.controls.previousExperience.invalid && form.controls.previousExperience.touched) { @if (form.controls.previousExperience.invalid && form.controls.previousExperience.touched) {
<mat-error> <mat-error>
@if (form.controls.previousExperience.errors?.['required']) { @if (form.controls.previousExperience.errors?.['required']) {
@ -233,9 +242,9 @@
<mat-form-field appearance="fill" style="width: 100%;"> <mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Experience with plugins that players use on our server</mat-label> <mat-label>Experience with plugins that players use on our server</mat-label>
<textarea matInput <textarea matInput
formControlName="pluginExperience" formControlName="pluginExperience"
placeholder="Describe your experience with our server plugins" placeholder="Describe your experience with our server plugins"
rows="4"></textarea> rows="4"></textarea>
@if (form.controls.pluginExperience.invalid && form.controls.pluginExperience.touched) { @if (form.controls.pluginExperience.invalid && form.controls.pluginExperience.touched) {
<mat-error> <mat-error>
@if (form.controls.pluginExperience.errors?.['required']) { @if (form.controls.pluginExperience.errors?.['required']) {
@ -251,9 +260,9 @@
<mat-form-field appearance="fill" style="width: 100%;"> <mat-form-field appearance="fill" style="width: 100%;">
<mat-label>What do you believe the expectations of a moderator are?</mat-label> <mat-label>What do you believe the expectations of a moderator are?</mat-label>
<textarea matInput <textarea matInput
formControlName="moderatorExpectations" formControlName="moderatorExpectations"
placeholder="Describe what you think a moderator should do" placeholder="Describe what you think a moderator should do"
rows="4"></textarea> rows="4"></textarea>
@if (form.controls.moderatorExpectations.invalid && form.controls.moderatorExpectations.touched) { @if (form.controls.moderatorExpectations.invalid && form.controls.moderatorExpectations.touched) {
<mat-error> <mat-error>
@if (form.controls.moderatorExpectations.errors?.['required']) { @if (form.controls.moderatorExpectations.errors?.['required']) {
@ -269,9 +278,9 @@
<mat-form-field appearance="fill" style="width: 100%;"> <mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Additional Information (Optional)</mat-label> <mat-label>Additional Information (Optional)</mat-label>
<textarea matInput <textarea matInput
formControlName="additionalInfo" formControlName="additionalInfo"
placeholder="Any additional information you'd like to share" placeholder="Any additional information you'd like to share"
rows="4"></textarea> rows="4"></textarea>
</mat-form-field> </mat-form-field>
</div> </div>
<button mat-raised-button (click)="onSubmit()" <button mat-raised-button (click)="onSubmit()"

View File

@ -99,6 +99,16 @@ main {
background-color: rgba(76, 175, 80, 0.1); background-color: rgba(76, 175, 80, 0.1);
} }
.invalid-email {
display: flex;
align-items: center;
color: #af4c4c;
margin: 10px 0;
padding: 8px 12px;
border-radius: 4px;
background-color: rgba(76, 175, 80, 0.1);
}
.valid-email mat-icon { .valid-email mat-icon {
color: #4CAF50; color: #4CAF50;
margin-right: 10px; margin-right: 10px;

View File

@ -107,7 +107,7 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
}), }),
availableTimes: new FormControl('', { availableTimes: new FormControl('', {
nonNullable: true, nonNullable: true,
validators: [Validators.required, Validators.maxLength(1000)] validators: [Validators.required, Validators.maxLength(900)]
}), }),
previousExperience: new FormControl('', { previousExperience: new FormControl('', {
nonNullable: true, nonNullable: true,
@ -234,10 +234,11 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
const staffApplication: StaffApplication = this.mapToStaffApplication(this.form.getRawValue()); const staffApplication: StaffApplication = this.mapToStaffApplication(this.form.getRawValue());
this.staffApplicationService.submitStaffApplication(staffApplication).subscribe(result => { this.staffApplicationService.submitStaffApplication(staffApplication).subscribe(result => {
//TODO route to mail page if (!result.verified_mail) {
// Navigate to the sent page throw new Error('Submitted a form with an e-mail that was not verified.');
}
this.router.navigate(['/forms/sent'], { this.router.navigate(['/forms/sent'], {
state: {message: 'Your staff application has been submitted successfully. We will review your application and get back to you soon.'} state: {message: result.message}
}).then(); }).then();
}) })
} }
@ -321,7 +322,7 @@ export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewIn
joinDate: joinDateString, joinDate: joinDateString,
weeklyPlaytime: Number(formData.weeklyPlaytime), weeklyPlaytime: Number(formData.weeklyPlaytime),
availableDays: formData.availableDays, availableDays: formData.availableDays,
availableTimes: formData.availableTimes, availableTimes: `Timezone: ${this.userTimezone}\nAvailable Times:${formData.availableTimes}`,
previousExperience: formData.previousExperience, previousExperience: formData.previousExperience,
pluginExperience: formData.pluginExperience, pluginExperience: formData.pluginExperience,
moderatorExpectations: formData.moderatorExpectations, moderatorExpectations: formData.moderatorExpectations,

View File

@ -66,7 +66,6 @@ components:
description: Preferred pronouns of the applicant description: Preferred pronouns of the applicant
joinDate: joinDate:
type: string type: string
maxLength: 256
format: date format: date
description: Date when the applicant joined the service description: Date when the applicant joined the service
weeklyPlaytime: weeklyPlaytime: