Implement Discord appeal functionality, including database schema, API endpoints, front-end form, and Discord message handling.

This commit is contained in:
2025-11-22 22:26:40 +01:00
parent 20ec3648c4
commit 7d59885395
30 changed files with 1143 additions and 38 deletions
+11
View File
@@ -150,6 +150,11 @@ export const routes: Routes = [
redirectTo: 'forms/appeal',
pathMatch: 'full'
},
{
path: 'discord-appeal',
redirectTo: 'forms/discord-appeal',
pathMatch: 'full'
},
{
path: 'forms/appeal/:code',
loadComponent: () => import('./pages/forms/appeal/appeal.component').then(m => m.AppealComponent),
@@ -162,6 +167,12 @@ export const routes: Routes = [
canActivate: [AuthGuard],
data: {requiredAuthorizations: ['SCOPE_user']}
},
{
path: 'forms/discord-appeal',
loadComponent: () => import('./pages/forms/discord-appeal/discord-appeal.component').then(m => m.DiscordAppealComponent),
canActivate: [AuthGuard],
data: {requiredAuthorizations: ['SCOPE_user']}
},
{
path: 'forms/sent',
loadComponent: () => import('./pages/forms/sent/sent.component').then(m => m.SentComponent),
@@ -0,0 +1,184 @@
<div>
<app-header [current_page]="'appeal'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5">
<div class="title" header-content>
<h1>Discord Appeal</h1>
</div>
</app-header>
<main>
<app-full-size>
<section class="darkmodeSection appeal-container">
<div class="form-container">
<div class="pages">
@if (currentPageIndex === 0) {
<section class="formPage">
<img ngSrc="/public/img/logos/logo.png" alt="Discord" height="319" width="550"/>
<h1>Discord Appeal</h1>
<p>We aim to respond within 48 hours.</p>
<button mat-raised-button (click)="nextPage()">
Next
</button>
</section>
}
@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 an appeal is <strong>not</strong> an instant process.
We will investigate the punishment you are appealing and respond within 48 hours.</p>
<p style="font-style: italic;">Appeals that seem to have been made with
little to no effort will be automatically denied.</p>
</div>
<button mat-raised-button (click)="nextPage()" [disabled]="authService.username() == null">
I, {{ authService.username() }}, understand and agree
</button>
</section>
}
@if (currentPageIndex === 2) {
<section class="formPage">
<div class="description">
<p>Please enter your discord id below.</p>
<p>You can find your discord id by going to User settings -> Advanced -> Developer Mode and turning it
on</p>
<p>With Developer Mode on in Discord click your profile in the bottom left and click Copy User Id</p>
<p>We use this to find your punishment on our Discord server.</p>
</div>
<mat-form-field appearance="fill">
<mat-label>Discord Id</mat-label>
<input matInput placeholder="Discord Id" [(ngModel)]="discordId"
maxlength="17" minlength="18" pattern="^[0-9]+$">
</mat-form-field>
<button mat-raised-button (click)="checkPunishment()" [disabled]="authService.username() == null">
Check punishments
</button>
</section>
}
@if (currentPageIndex === 3) {
@if (bannedUser == null) {
<section class="formPage">
<div class="description">
<p>We were unable to find your punishment on our Discord server.</p>
</div>
</section>
} @else if (bannedUser.isBanned || bannedUser.bannedUser == null) {
<section class="formPage">
<div class="description">
<p>Your discord account is not banned on our Discord server.</p>
</div>
</section>
} @else {
<section class="formPage">
<div class="description">
<img ngSrc="{{ bannedUser.bannedUser.avatarUrl }}" title="{{ bannedUser.bannedUser.name }}"
width="128" height="128" class="discord-avatar"
alt="Avatar for Discord user {{ bannedUser.bannedUser.name }}">
<p style="text-align: center">{{ bannedUser.bannedUser.name }}</p>
<p style="margin-top: 30px;">Your punishment is: <strong>{{ bannedUser.bannedUser.reason }}</strong>
</p>
<button style="display: block; margin-top: 30px;" class="centered" mat-raised-button
(click)="nextPage()"
[disabled]="authService.username() == null">
This is my punishment, continue to appeal
</button>
</div>
</section>)
}
}
@if (currentPageIndex >= 4) {
<form [formGroup]="form">
@if (currentPageIndex === 4) {
<section class="formPage">
<div class="description">
<h2>Please enter your email.</h2>
<p style="font-style: italic">It does not have to be your minecraft email. You will have to verify
it</p>
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Email</mat-label>
<input matInput
formControlName="email"
placeholder="Email"
type="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>
}
</div>
<button mat-raised-button (click)="validateMailOrNextPage()"
[disabled]="form.controls.email.invalid">
Next
</button>
</section>
}
@if (currentPageIndex === 5) {
<section class="formPage">
<div class="description">
<h2>Why should your ban be reduced or removed?</h2>
<p style="font-style: italic">Please take your time writing this, we're more likely to accept an
appeal if effort was put into it.</p>
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>Reason</mat-label>
<textarea matInput formControlName="appeal" placeholder="Reason" rows="6"></textarea>
@if (form.controls.appeal.invalid && form.controls.appeal.touched) {
<mat-error>
@if (form.controls.appeal.errors?.['required']) {
Reason is required
} @else if (form.controls.appeal.errors?.['minlength']) {
Reason must be at least 10 characters
}
</mat-error>
}
</mat-form-field>
</div>
<button mat-raised-button (click)="onSubmit()" [disabled]="form.invalid">
Submit Appeal
</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>
</app-full-size>
</main>
</div>
@@ -0,0 +1,121 @@
:host {
display: block;
}
.appeal-container {
display: flex;
flex-direction: column;
height: 100%;
}
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;
}
.discord-avatar {
width: 128px;
height: 128px;
border-radius: 50%;
object-fit: cover;
display: block;
margin-left: auto;
margin-right: auto;
}
@@ -0,0 +1,199 @@
import {Component, computed, effect, inject, OnInit, signal} from '@angular/core';
import {AppealsService, BannedUserResponse, DiscordAppeal, EmailEntry, MailService} from '@api';
import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
import {HeaderComponent} from '@header/header.component';
import {MatButton, MatIconButton} from '@angular/material/button';
import {NgOptimizedImage} from '@angular/common';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {AuthService} from '@services/auth.service';
import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from '@angular/forms';
import {MatFormFieldModule} from '@angular/material/form-field';
import {MatInputModule} from '@angular/material/input';
import {MatIconModule} from '@angular/material/icon';
import {VerifyMailDialogComponent} from '@pages/forms/verify-mail-dialog/verify-mail-dialog.component';
import {MatDialog} from '@angular/material/dialog';
import {Router} from '@angular/router';
@Component({
selector: 'app-discord-appeal',
imports: [
FullSizeComponent,
HeaderComponent,
MatButton,
MatProgressSpinnerModule,
NgOptimizedImage,
FormsModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
ReactiveFormsModule,
MatIconButton,
],
templateUrl: './discord-appeal.component.html',
styleUrl: './discord-appeal.component.scss'
})
export class DiscordAppealComponent implements OnInit {
private readonly appealService: AppealsService = inject(AppealsService)
private readonly mailService = inject(MailService);
private readonly dialog = inject(MatDialog);
private readonly router = inject(Router)
private emails = signal<EmailEntry[]>([]);
protected readonly authService = inject(AuthService);
protected bannedUser: BannedUserResponse | null = null;
protected discordId: string = window.location.hostname === 'localhost' ? '212303885988134914' : '';
protected verifiedEmails = computed(() => this.emails()
.filter(email => {
console.log(email.verified)
return email.verified
})
.map(email => {
console.log(email.email.toLowerCase())
return email.email.toLowerCase()
}));
protected emailIsValid = signal<boolean>(false);
protected currentPageIndex: number = 0;
protected totalPages: number[] = [0];
protected form: FormGroup<WebDiscordAppeal>;
constructor() {
this.form = new FormGroup({
email: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.email]}),
appeal: new FormControl('', {nonNullable: true, validators: [Validators.required, Validators.minLength(10)]})
});
effect(() => {
if (this.verifiedEmails().length > 0) {
console.log('verified emails')
console.log(this.verifiedEmails()[0])
this.form.get('email')?.setValue(this.verifiedEmails()[0]);
this.emailIsValid.set(true);
}
});
}
ngOnInit(): void {
if (window.location.hostname === 'localhost') {
this.emails.set([{email: '[email protected]', verified: true}])
} else {
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);
}
});
}
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);
}
});
}
public goToPage(pageIndex: number): void {
if (pageIndex >= 0 && pageIndex < this.totalPages.length) {
this.currentPageIndex = pageIndex;
}
}
public previousPage() {
this.goToPage(this.currentPageIndex - 1);
}
public nextPage() {
if (this.currentPageIndex === this.totalPages.length - 1) {
this.totalPages.push(this.currentPageIndex + 1);
}
this.goToPage(this.currentPageIndex + 1);
}
public isFirstPage(): boolean {
return this.currentPageIndex === 0;
}
public isLastPage(): boolean {
return this.currentPageIndex === this.totalPages.length - 1;
}
protected checkPunishment() {
if (window.location.hostname === 'localhost') {
this.bannedUser = {
isBanned: false,
bannedUser: {
userId: 212303885988134914,
reason: "This is a test punishment",
name: "stijn",
avatarUrl: "https://cdn.discordapp.com/avatars/212303885988134914/3a264be54ca7208d638a22143fc8fdb8.webp?size=160"
}
}
this.nextPage();
return
}
this.appealService.getBannedUser(Number(this.discordId))
.subscribe(user => {
this.bannedUser = user
this.nextPage();
});
}
protected onSubmit() {
if (this.form === undefined) {
console.error('Form is undefined');
return
}
if (this.form.valid) {
this.sendForm()
} else {
Object.keys(this.form.controls).forEach(field => {
const control = this.form!.get(field);
if (!(control instanceof FormGroup)) {
console.error('Control [' + control + '] is not a FormGroup');
return;
}
control.markAsTouched({onlySelf: true});
});
}
}
private sendForm() {
const rawValue = this.form.getRawValue();
const uuid = this.authService.getUuid();
if (uuid === null) {
throw new Error('JWT subject is null, are you logged in?');
}
const appeal: DiscordAppeal = {
discordId: Number(this.discordId),
appeal: rawValue.appeal,
email: rawValue.email,
}
this.appealService.submitDiscordAppeal(appeal).subscribe((result) => {
if (!result.verified_mail) {
throw new Error('Mail not verified');
}
this.router.navigate(['/forms/sent'], {
state: {message: result.message}
}).then();
})
}
}
interface WebDiscordAppeal {
email: FormControl<string>;
appeal: FormControl<string>;
}
@@ -34,7 +34,7 @@
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
@if (!staffPt()?.length) {
@if (!staffPt().length) {
<tr class="no-data">
<td colspan="3">No data for this week.</td>
</tr>
@@ -132,8 +132,8 @@
<li class="nav_li"><a class="nav_link2" [routerLink]="['/community']">Community</a></li>
<li class="nav_li"><a class="nav_link2" target="_blank" rel="noopener" [routerLink]="['/contact']">Contact
Us</a></li>
<li class="nav_li"><a class="nav_link2" target="_blank" rel="noopener" href="https://alttd.com/appeal">Ban
Appeal</a></li>
<li class="nav_li"><a class="nav_link2" [routerLink]="['/appeal']">Ban Appeal</a></li>
<li class="nav_li"><a class="nav_link2" [routerLink]="['/discord-appeal']">Discord Ban Appeal</a></li>
<li class="nav_li"><a class="nav_link2" target="_blank" href="https://alttd.com/blog/">Blog</a></li>
</ul>
</li>
@@ -40,7 +40,7 @@
<mat-form-field
class="colorField"
appearance="outline"
[style.visibility]="(part.continuation && i>0 && parts[i-1]?.gradient && part.gradient) ? 'hidden' : 'visible'">
[style.visibility]="(part.continuation && i>0 && parts[i-1].gradient && part.gradient) ? 'hidden' : 'visible'">
<mat-label>Color A</mat-label>
<input
matInput
@@ -67,7 +67,7 @@
class="checkbox"
[(ngModel)]="part.continuation"
(change)="onContinuationToggle(i)"
[disabled]="i===0 || !part.gradient || !parts[i-1]?.gradient"
[disabled]="i===0 || !part.gradient || !parts[i-1].gradient"
>Continuation
</mat-checkbox
>
@@ -45,6 +45,9 @@ export class AuthService {
}
private reloadUsername() {
if (window.location.hostname === 'localhost') {
this._username.set('developer');
}
this.loginService.getUsername().subscribe({
next: (username) => {
this._username.set(username.username);