Add staff application feature with API integration and frontend form implementation

This commit is contained in:
akastijn 2025-09-24 22:26:17 +02:00
parent 2a0f38aa28
commit f886609a0e
13 changed files with 992 additions and 28 deletions

View File

@ -40,6 +40,10 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-mail:3.1.5")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
//Open API
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
implementation("io.swagger.core.v3:swagger-models:2.2.37")
//AOP
implementation("org.aspectj:aspectjrt:1.9.19")
implementation("org.aspectj:aspectjweaver:1.9.19")

View File

@ -10,8 +10,8 @@ import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.mappers.AppealDataMapper;
import com.alttd.altitudeweb.model.AppealResponseDto;
import com.alttd.altitudeweb.model.DiscordAppealDto;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.model.MinecraftAppealDto;
import com.alttd.altitudeweb.model.UpdateMailDto;
import com.alttd.altitudeweb.services.limits.RateLimit;
@ -25,7 +25,6 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@ -41,13 +40,13 @@ public class AppealController implements AppealsApi {
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
@Override
public ResponseEntity<AppealResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
}
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
@Override
public ResponseEntity<AppealResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
public ResponseEntity<FormResponseDto> submitMinecraftAppeal(MinecraftAppealDto minecraftAppealDto) {
boolean success = true;
CompletableFuture<Appeal> appealCompletableFuture = new CompletableFuture<>();
@ -105,7 +104,7 @@ public class AppealController implements AppealsApi {
sqlSession.getMapper(AppealMapper.class)
.markAppealAsSent(appeal.id());
});
AppealResponseDto appealResponseDto = new AppealResponseDto(
FormResponseDto appealResponseDto = new FormResponseDto(
appeal.id().toString(),
"Your appeal has been submitted. You will be notified when it has been reviewed.",
true);
@ -113,7 +112,8 @@ public class AppealController implements AppealsApi {
}
@Override
public ResponseEntity<AppealResponseDto> updateMail(UpdateMailDto updateMailDto) {
public ResponseEntity<FormResponseDto> updateMail(UpdateMailDto updateMailDto) {
//TODO move to its own endpoint
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Updating mail is not yet supported");
}

View File

@ -0,0 +1,15 @@
package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.ApplicationsApi;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.model.StaffApplicationDto;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
public class ApplicationController implements ApplicationsApi {
@Override
public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Staff applications are not yet supported");
}
}

View File

@ -126,6 +126,14 @@ export const routes: Routes = [
requiredAuthorizations: ['SCOPE_user']
}
},
{
path: 'forms/staff-application',
loadComponent: () => import('./pages/forms/staff-application/staff-application.component').then(m => m.StaffApplicationComponent),
canActivate: [AuthGuard],
data: {
requiredAuthorizations: ['SCOPE_user']
}
},
{
path: 'community',
loadComponent: () => import('./pages/altitude/community/community.component').then(m => m.CommunityComponent)

View File

@ -17,6 +17,14 @@
</p>
</a>
</div>
<div class="columnParagraph">
<a [routerLink]="['/forms/staff-application']">
<h2>Staff Application</h2>
<p>
Interested in becoming a moderator on our server? Apply here.
</p>
</a>
</div>
</div>
</section>
</section>

View File

@ -0,0 +1,309 @@
<div>
<app-header [current_page]="'staff-application'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Staff Application</h1>
</div>
</app-header>
<main>
<section class="darkmodeSection staff-application-container">
<div class="form-container">
<div class="pages">
<!-- Welcome Page -->
@if (currentPageIndex === 0) {
<section class="formPage">
<img ngSrc="/public/img/logos/logo.png" alt="Logo" height="319" width="550"/>
<h1>Moderator Application</h1>
<p>Thank you for your interest in becoming a moderator on our Minecraft server.</p>
<p>Please take your time to fill out this application thoroughly.</p>
<button mat-raised-button (click)="nextPage()">
Get Started
</button>
</section>
}
<!-- Confirmation Page -->
@if (currentPageIndex === 1) {
<section class="formPage">
<div class="description">
<p>You are logged in as <strong>{{ authService.username() }}</strong>. If this is the correct account
please continue</p>
<br>
<p><strong>Notice: </strong> Submitting a staff application is <strong>not</strong> an instant process.
We will review your application carefully and get back to you if we think you're a good fit.</p>
<p style="font-style: italic;">Applications that seem to have been made with
little to no effort will be automatically rejected.</p>
</div>
<button mat-raised-button (click)="nextPage()" [disabled]="authService.username() == null">
I, {{ authService.username() }}, understand and agree
</button>
</section>
}
<form [formGroup]="form">
<!-- Basic Information Page -->
@if (currentPageIndex === 2) {
<section class="formPage">
<div class="description">
<h2>Basic Information</h2>
<!-- Email -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Email</mat-label>
<input matInput
formControlName="email"
placeholder="Email">
@if (form.controls.email.invalid && form.controls.email.touched) {
<mat-error>
@if (form.controls.email.errors?.['required']) {
Email is required
} @else if (form.controls.email.errors?.['email']) {
Please enter a valid email address
}
</mat-error>
}
</mat-form-field>
@if (emailIsValid()) {
<div class="valid-email">
<ng-container matSuffix>
<mat-icon>check</mat-icon>
<span>You have validated your email previously, and can continue to the next page!</span>
</ng-container>
</div>
}
<!-- Age -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Age</mat-label>
<input matInput
formControlName="age"
placeholder="Age">
@if (form.controls.age.invalid && form.controls.age.touched) {
<mat-error>
@if (form.controls.age.errors?.['required']) {
Age is required
} @else if (form.controls.age.errors?.['min']) {
You must be at least 13 years old
} @else if (form.controls.age.errors?.['pattern']) {
Please enter a valid number
}
</mat-error>
}
</mat-form-field>
<!-- Discord Username -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Discord Username</mat-label>
<input matInput
formControlName="discordUsername"
placeholder="Discord Username">
@if (form.controls.discordUsername.invalid && form.controls.discordUsername.touched) {
<mat-error>
Discord username is required
</mat-error>
}
</mat-form-field>
<!-- PC Requirements -->
<div class="checkbox-field">
<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)
</mat-checkbox>
@if (form.controls.meetsRequirements.invalid && form.controls.meetsRequirements.touched) {
<mat-error class="checkbox-error">
You must meet the PC requirements to apply
</mat-error>
}
</div>
<!-- Pronouns (Optional) -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Pronouns (Optional)</mat-label>
<input matInput
formControlName="pronouns"
placeholder="Pronouns">
</mat-form-field>
</div>
<button mat-raised-button (click)="validateMailOrNextPage()"
[disabled]="form.controls.email.invalid || form.controls.age.invalid || form.controls.discordUsername.invalid || !form.controls.meetsRequirements.value">
Next
</button>
</section>
}
<!-- Experience Page -->
@if (currentPageIndex === 3) {
<section class="formPage">
<div class="description">
<h2>Experience & Availability</h2>
<!-- Join Date -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>When did you join our server? (Estimate)</mat-label>
<input matInput [matDatepicker]="picker" formControlName="joinDate">
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
@if (form.controls.joinDate.invalid && form.controls.joinDate.touched) {
<mat-error>
Join date is required
</mat-error>
}
</mat-form-field>
<!-- Weekly Playtime -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Average expected playtime in a week (hours)</mat-label>
<input matInput
formControlName="weeklyPlaytime"
placeholder="Hours per week">
@if (form.controls.weeklyPlaytime.invalid && form.controls.weeklyPlaytime.touched) {
<mat-error>
@if (form.controls.weeklyPlaytime.errors?.['required']) {
Weekly playtime is required
} @else if (form.controls.weeklyPlaytime.errors?.['min']) {
Weekly playtime must be at least 1 hour
}
</mat-error>
}
</mat-form-field>
<!-- Available Days -->
<div class="field-container">
<label class="field-label">Available days for moderating:</label>
<div class="days-container">
@for (day of availableDays; track day) {
<div class="day-chip" [class.selected]="form.controls.availableDays.value.includes(day)" (click)="toggleDay(day)">
{{ day }}
</div>
}
</div>
@if (form.controls.availableDays.invalid && form.controls.availableDays.touched) {
<mat-error>
Please select at least one day
</mat-error>
}
</div>
<!-- Available Times -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Available times (Your timezone: {{ userTimezone }})</mat-label>
<textarea matInput
formControlName="availableTimes"
placeholder="e.g., 6PM-10PM weekdays, 2PM-8PM weekends"
rows="2"></textarea>
@if (form.controls.availableTimes.invalid && form.controls.availableTimes.touched) {
<mat-error>
Available times are required
</mat-error>
}
</mat-form-field>
</div>
<button mat-raised-button (click)="nextPage()"
[disabled]="form.controls.joinDate.invalid || form.controls.weeklyPlaytime.invalid || form.controls.availableDays.invalid || form.controls.availableTimes.invalid">
Next
</button>
</section>
}
<!-- Qualifications Page -->
@if (currentPageIndex === 4) {
<section class="formPage">
<div class="description">
<h2>Qualifications & Expectations</h2>
<!-- Previous Experience -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Previous experience (here or in other relevant places)</mat-label>
<textarea matInput
formControlName="previousExperience"
placeholder="Describe your previous experience"
rows="4"></textarea>
@if (form.controls.previousExperience.invalid && form.controls.previousExperience.touched) {
<mat-error>
@if (form.controls.previousExperience.errors?.['required']) {
Previous experience is required
} @else if (form.controls.previousExperience.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Plugin Experience -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Experience with plugins that players use on our server</mat-label>
<textarea matInput
formControlName="pluginExperience"
placeholder="Describe your experience with our server plugins"
rows="4"></textarea>
@if (form.controls.pluginExperience.invalid && form.controls.pluginExperience.touched) {
<mat-error>
@if (form.controls.pluginExperience.errors?.['required']) {
Plugin experience is required
} @else if (form.controls.pluginExperience.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Moderator Expectations -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>What do you believe the expectations of a moderator are?</mat-label>
<textarea matInput
formControlName="moderatorExpectations"
placeholder="Describe what you think a moderator should do"
rows="4"></textarea>
@if (form.controls.moderatorExpectations.invalid && form.controls.moderatorExpectations.touched) {
<mat-error>
@if (form.controls.moderatorExpectations.errors?.['required']) {
Moderator expectations are required
} @else if (form.controls.moderatorExpectations.errors?.['minlength']) {
Please provide more details (at least 10 characters)
}
</mat-error>
}
</mat-form-field>
<!-- Additional Information -->
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Additional Information (Optional)</mat-label>
<textarea matInput
formControlName="additionalInfo"
placeholder="Any additional information you'd like to share"
rows="4"></textarea>
</mat-form-field>
</div>
<button mat-raised-button (click)="onSubmit()"
[disabled]="form.controls.previousExperience.invalid || form.controls.pluginExperience.invalid || form.controls.moderatorExpectations.invalid">
Submit Application
</button>
</section>
}
</form>
</div>
<!-- Navigation dots -->
@if (totalPages.length > 1) {
<div class="form-navigation">
<button mat-icon-button class="nav-button" (click)="previousPage()" [disabled]="isFirstPage()">
<mat-icon>navigate_before</mat-icon>
</button>
@for (i of totalPages; track i) {
<div
class="nav-dot"
[class.active]="i === currentPageIndex"
(click)="goToPage(i)">
</div>
}
<button mat-icon-button class="nav-button" (click)="nextPage()" [disabled]="isLastPage()">
<mat-icon>navigate_next</mat-icon>
</button>
</div>
}
</div>
</section>
</main>
</div>

View File

@ -0,0 +1,164 @@
:host {
display: block;
}
.staff-application-container {
display: flex;
flex-direction: column;
min-height: 80vh;
}
main {
flex: 1;
display: flex;
flex-direction: column;
}
.form-container {
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
flex: 1;
}
.formPage {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
width: 100%;
height: 100%;
animation: fadeIn 0.5s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.navigation-buttons {
display: flex;
gap: 16px;
margin-top: 20px;
}
.form-navigation {
display: flex;
justify-content: center;
gap: 10px;
position: absolute;
bottom: 0;
left: 0;
right: 0;
}
.nav-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
cursor: pointer;
transition: background-color 0.3s ease;
margin-top: auto;
margin-bottom: auto;
&.active {
background-color: #fff;
}
}
.nav-button {
color: #1f9bde;
}
.pages {
margin-top: auto;
margin-bottom: auto;
}
.description {
max-width: 75ch;
text-align: left;
}
.valid-email {
display: flex;
align-items: center;
color: #4CAF50;
margin: 10px 0;
padding: 8px 12px;
border-radius: 4px;
background-color: rgba(76, 175, 80, 0.1);
}
.valid-email mat-icon {
color: #4CAF50;
margin-right: 10px;
}
.valid-email span {
color: #4CAF50;
font-weight: 500;
}
.checkbox-field {
margin: 16px 0;
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
}
.checkbox-error {
color: #f44336;
font-size: 12px;
margin-top: 4px;
}
.field-container {
margin: 16px 0;
width: 100%;
}
.field-label {
font-size: 16px;
margin-bottom: 8px;
display: block;
color: rgba(255, 255, 255, 0.7);
}
.days-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.day-chip {
padding: 8px 12px;
border-radius: 16px;
background-color: rgba(255, 255, 255, 0.1);
cursor: pointer;
transition: all 0.3s ease;
&.selected {
background-color: #1f9bde;
color: white;
}
&:hover {
background-color: rgba(255, 255, 255, 0.2);
}
}
mat-form-field {
margin-bottom: 16px;
}

View File

@ -0,0 +1,348 @@
import {
AfterViewInit,
Component,
computed,
ElementRef,
inject,
OnDestroy,
OnInit,
Renderer2,
signal
} from '@angular/core';
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
import {ApplicationsService, EmailEntry, MailService, StaffApplication} from '@api';
import {HeaderComponent} from '@header/header.component';
import {NgOptimizedImage} from '@angular/common';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {AuthService} from '@services/auth.service';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatSelectModule} from '@angular/material/select';
import {MatInputModule} from '@angular/material/input';
import {MatDialog} from '@angular/material/dialog';
import {VerifyMailDialogComponent} from '@pages/forms/verify-mail-dialog/verify-mail-dialog.component';
import {Router} from '@angular/router';
import {MatCheckboxModule} from '@angular/material/checkbox';
import {MatDatepickerModule} from '@angular/material/datepicker';
import {MatNativeDateModule} from '@angular/material/core';
import {MatChipsModule} from '@angular/material/chips';
@Component({
selector: 'app-staff-application',
imports: [
HeaderComponent,
NgOptimizedImage,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatFormFieldModule,
MatSelectModule,
MatInputModule,
ReactiveFormsModule,
MatCheckboxModule,
MatDatepickerModule,
MatNativeDateModule,
MatChipsModule
],
templateUrl: './staff-application.component.html',
styleUrl: './staff-application.component.scss'
})
export class StaffApplicationComponent implements OnInit, OnDestroy, AfterViewInit {
private mailService = inject(MailService);
public authService = inject(AuthService);
public staffApplicationService = inject(ApplicationsService)
private resizeObserver: ResizeObserver | null = null;
private boundHandleResize: any;
protected form: FormGroup<StaffApplicationForm>;
private emails = signal<EmailEntry[]>([]);
protected verifiedEmails = computed(() => this.emails()
.filter(email => email.verified)
.map(email => email.email.toLowerCase()));
protected emailIsValid = signal<boolean>(false);
protected dialog = inject(MatDialog);
protected availableDays: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
protected selectedDays: string[] = [];
protected userTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
constructor(
private elementRef: ElementRef,
private renderer: Renderer2
) {
const staffApplication: StaffApplicationForm = {
email: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email, Validators.maxLength(320)]
}),
age: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.min(13), Validators.pattern('^[0-9]*$')]
}),
discordUsername: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(32)]
}),
meetsRequirements: new FormControl(false, {
nonNullable: true,
validators: [Validators.requiredTrue]
}),
pronouns: new FormControl('', {
nonNullable: true,
validators: [Validators.maxLength(32)]
}),
joinDate: new FormControl('', {
nonNullable: true,
validators: [Validators.required]
}),
weeklyPlaytime: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.min(1)]
}),
availableDays: new FormControl([], {
nonNullable: true,
validators: [Validators.required]
}),
availableTimes: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(1000)]
}),
previousExperience: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
}),
pluginExperience: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
}),
moderatorExpectations: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(10), Validators.maxLength(4000)]
}),
additionalInfo: new FormControl('', {
nonNullable: true,
validators: [Validators.maxLength(4000)]
})
}
this.form = new FormGroup(staffApplication);
this.mailService.getUserEmails().subscribe(emails => {
this.emails.set(emails);
});
this.form.valueChanges.subscribe(() => {
if (this.verifiedEmails().includes(this.form.getRawValue().email.toLowerCase())) {
this.emailIsValid.set(true);
} else {
this.emailIsValid.set(false);
}
});
computed(() => {
if (this.verifiedEmails().length > 0) {
this.form.get('email')?.setValue(this.verifiedEmails()[0]);
this.emailIsValid.set(true);
}
});
}
ngOnInit() {
const uuid = this.authService.getUuid();
if (uuid === null) {
alert('Error retrieving token, please relog on the website and try again')
throw new Error('JWT subject is null, are you logged in?');
}
}
ngAfterViewInit() {
this.setupResizeObserver();
this.updateContainerHeight();
this.boundHandleResize = this.handleResize.bind(this);
window.addEventListener('resize', this.boundHandleResize);
setTimeout(() => this.updateContainerHeight(), 0);
}
ngOnDestroy() {
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
if (this.boundHandleResize) {
window.removeEventListener('resize', this.boundHandleResize);
}
}
private handleResize() {
this.updateContainerHeight();
}
private setupResizeObserver() {
this.resizeObserver = new ResizeObserver(() => {
this.updateContainerHeight();
});
const headerElement = document.querySelector('app-header');
if (headerElement) {
this.resizeObserver.observe(headerElement);
}
const footerElement = document.querySelector('footer');
if (footerElement) {
this.resizeObserver.observe(footerElement);
}
}
private updateContainerHeight() {
const headerElement = document.querySelector('app-header');
const footerElement = document.querySelector('footer');
const container = this.elementRef.nativeElement.querySelector('.staff-application-container');
if (headerElement && footerElement && container) {
const headerHeight = headerElement.getBoundingClientRect().height;
const footerHeight = footerElement.getBoundingClientRect().height;
const calculatedHeight = `calc(100vh - ${headerHeight}px - ${footerHeight}px)`;
this.renderer.setStyle(container, 'min-height', calculatedHeight);
}
}
public onSubmit() {
if (this.form === undefined) {
console.error('Form is undefined');
return
}
if (this.form.valid) {
this.sendForm()
} else {
// Mark all fields as touched to show validation errors
Object.keys(this.form.controls).forEach(field => {
const control = this.form.get(field);
control?.markAsTouched();
});
}
}
private router = inject(Router)
private sendForm() {
const staffApplication: StaffApplication = this.mapToStaffApplication(this.form.getRawValue());
this.staffApplicationService.submitStaffApplication(staffApplication).subscribe(result => {
//TODO route to mail page
// Navigate to the sent page
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.'}
}).then();
})
}
public currentPageIndex: number = 0;
public totalPages: number[] = [0, 1, 2, 3, 4];
public goToPage(pageIndex: number): void {
if (pageIndex >= 0 && pageIndex < this.totalPages.length) {
this.currentPageIndex = pageIndex;
}
}
public previousPage() {
this.goToPage(this.currentPageIndex - 1);
}
public nextPage() {
this.goToPage(this.currentPageIndex + 1);
}
public isFirstPage(): boolean {
return this.currentPageIndex === 0;
}
public isLastPage(): boolean {
return this.currentPageIndex === this.totalPages.length - 1;
}
protected validateMailOrNextPage() {
if (this.emailIsValid()) {
this.nextPage();
return;
}
const dialogRef = this.dialog.open(VerifyMailDialogComponent, {
data: {email: this.form.getRawValue().email},
});
dialogRef.afterClosed().subscribe(result => {
if (result === true) {
this.emailIsValid.set(true);
this.nextPage();
}
});
}
toggleDay(day: string) {
const availableDaysControl = this.form.get('availableDays');
const currentDays = [...(availableDaysControl?.value || [])];
if (currentDays.includes(day)) {
const index = currentDays.indexOf(day);
currentDays.splice(index, 1);
} else {
currentDays.push(day);
}
availableDaysControl?.setValue(currentDays);
}
private mapToStaffApplication(formData: any): StaffApplication {
let joinDateString: string;
if (formData.joinDate instanceof Date) {
joinDateString = formData.joinDate.toISOString();
} else if (typeof formData.joinDate === 'string' && formData.joinDate.trim() !== '') {
const parsedDate = new Date(formData.joinDate);
if (isNaN(parsedDate.getTime())) {
throw new Error('Invalid date string');
}
joinDateString = parsedDate.toISOString();
} else {
throw new Error('Invalid date string');
}
return {
email: formData.email,
age: Number(formData.age),
discordUsername: formData.discordUsername,
meetsRequirements: formData.meetsRequirements,
pronouns: formData.pronouns || '',
joinDate: joinDateString,
weeklyPlaytime: Number(formData.weeklyPlaytime),
availableDays: formData.availableDays,
availableTimes: formData.availableTimes,
previousExperience: formData.previousExperience,
pluginExperience: formData.pluginExperience,
moderatorExpectations: formData.moderatorExpectations,
additionalInfo: formData.additionalInfo || ''
};
}
}
interface StaffApplicationForm {
email: FormControl<string>;
age: FormControl<string>;
discordUsername: FormControl<string>;
meetsRequirements: FormControl<boolean>;
pronouns: FormControl<string>;
joinDate: FormControl<string>;
weeklyPlaytime: FormControl<string>;
availableDays: FormControl<string[]>;
availableTimes: FormControl<string>;
previousExperience: FormControl<string>;
pluginExperience: FormControl<string>;
moderatorExpectations: FormControl<string>;
additionalInfo: FormControl<string>;
}

View File

@ -43,9 +43,9 @@ sourceSets {
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("io.swagger.core.v3:swagger-annotations:2.2.20")
implementation("io.swagger.core.v3:swagger-models:2.2.8")
implementation("io.swagger.core.v3:swagger-core:2.2.8")
implementation("io.swagger.core.v3:swagger-annotations:2.2.37")
implementation("io.swagger.core.v3:swagger-models:2.2.37")
implementation("io.swagger.core.v3:swagger-core:2.2.37")
implementation("org.openapitools:jackson-databind-nullable:0.2.6")
implementation("org.springframework.hateoas:spring-hateoas:2.2.0")

View File

@ -55,6 +55,8 @@ paths:
$ref: './schemas/forms/appeal/appeal.yml#/MinecraftAppeal'
/api/appeal/discord-appeal:
$ref: './schemas/forms/appeal/appeal.yml#/DiscordAppeal'
/api/apply/staff-application:
$ref: './schemas/forms/staff_apply/staff_apply.yml#/StaffApply'
/api/login/requestNewUserLogin/{uuid}:
$ref: './schemas/login/login.yml#/RequestNewUserLogin'
/api/login/userLogin/{code}:

View File

@ -23,7 +23,7 @@ UpdateMail:
content:
application/json:
schema:
$ref: '#/components/schemas/AppealResponse'
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
default:
description: Unexpected error
content:
@ -49,7 +49,7 @@ MinecraftAppeal:
content:
application/json:
schema:
$ref: '#/components/schemas/AppealResponse'
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
default:
description: Unexpected error
content:
@ -75,7 +75,7 @@ DiscordAppeal:
content:
application/json:
schema:
$ref: '#/components/schemas/AppealResponse'
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
default:
description: Unexpected error
content:
@ -136,22 +136,6 @@ components:
appeal:
type: string
description: Appeal text explaining why the punishment should be reconsidered
AppealResponse:
type: object
required:
- id
- message
- verified_mail
properties:
id:
type: string
description: Unique identifier for the submitted appeal for referring to it later
message:
type: string
description: Confirmation message
verified_mail:
type: boolean
description: If this user has verified their mail already
UpdateMail:
type: object
required:

View File

@ -0,0 +1,104 @@
StaffApply:
post:
tags:
- applications
summary: Submit a Staff appeal
description: Submit an staff application
operationId: submitStaffApplication
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/StaffApplication'
responses:
'201':
description: Application created please verify email
content:
application/json:
schema:
$ref: '../../generic/components.yml#/components/schemas/FormResponse'
default:
description: Unexpected error
content:
application/json:
schema:
$ref: '../../generic/errors.yml#/components/schemas/ApiError'
components:
schemas:
StaffApplication:
type: object
description: Schema for staff application
required:
- email
- age
- discordUsername
- meetsRequirements
- pronouns
- joinDate
- weeklyPlaytime
- availableDays
- availableTimes
- previousExperience
- pluginExperience
- moderatorExpectations
- additionalInfo
properties:
email:
type: string
format: email
maxLength: 320
description: Email address of the applicant
age:
type: integer
minimum: 13
description: Age of the applicant, must be 13 or older
discordUsername:
type: string
maxLength: 32
description: Discord username of the applicant
meetsRequirements:
type: boolean
description: Confirmation that the applicant meets all requirements
pronouns:
type: string
maxLength: 32
description: Preferred pronouns of the applicant
joinDate:
type: string
maxLength: 256
format: date
description: Date when the applicant joined the service
weeklyPlaytime:
type: integer
minimum: 1
description: Average weekly playtime in hours
availableDays:
type: array
items:
type: string
maxLength: 256
description: Days of the week when the applicant is available
availableTimes:
type: string
maxLength: 1000
description: Time ranges when the applicant is available
previousExperience:
type: string
minLength: 10
maxLength: 4000
description: Description of previous relevant experience
pluginExperience:
type: string
minLength: 10
maxLength: 4000
description: Description of experience with plugins
moderatorExpectations:
type: string
minLength: 10
maxLength: 4000
description: Applicant's expectations and understanding of moderator responsibilities
additionalInfo:
type: string
maxLength: 4000
description: Any additional information the applicant wishes to provide

View File

@ -0,0 +1,18 @@
components:
schemas:
FormResponse:
type: object
required:
- id
- message
- verified_mail
properties:
id:
type: string
description: Unique identifier for the submitted form for referring to it later
message:
type: string
description: Confirmation message
verified_mail:
type: boolean
description: If this user has verified their mail already