Add staff application email and Discord notification integration

This commit is contained in:
akastijn 2025-09-24 23:33:36 +02:00
parent 643b15f2e0
commit cdbf862ecf
4 changed files with 344 additions and 1 deletions

View File

@ -10,7 +10,9 @@ import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.mappers.StaffApplicationDataMapper;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.model.StaffApplicationDto;
import com.alttd.altitudeweb.services.discord.StaffApplicationDiscord;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.StaffApplicationMail;
import com.alttd.altitudeweb.setup.Connection;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -29,6 +31,8 @@ import java.util.concurrent.TimeUnit;
public class ApplicationController implements ApplicationsApi {
private final StaffApplicationDataMapper staffApplicationDataMapper;
private final StaffApplicationMail staffApplicationMail;
private final StaffApplicationDiscord staffApplicationDiscord;
@Override
public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) {
@ -40,8 +44,35 @@ public class ApplicationController implements ApplicationsApi {
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, application.email());
boolean verified = optionalEmail.map(EmailVerification::verified).orElse(false);
boolean success = true;
if (verified) {
markAsSent(application.id());
// 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;
}
if (mailSent) {
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();
}
FormResponseDto response = buildResponse(application, verified);

View File

@ -0,0 +1,103 @@
package com.alttd.altitudeweb.services.discord;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.discord.OutputChannel;
import com.alttd.altitudeweb.database.discord.OutputChannelMapper;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import com.alttd.altitudeweb.setup.Connection;
import com.alttd.webinterface.send_message.DiscordSender;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
public class StaffApplicationDiscord {
private static final String OUTPUT_TYPE = "STAFF_APPLICATION";
public void sendApplicationToDiscord(StaffApplication application) {
// Fetch channels for staff applications
CompletableFuture<List<OutputChannel>> channelsFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DISCORD).runQuery(sql -> {
try {
List<OutputChannel> channels = sql.getMapper(OutputChannelMapper.class)
.getChannelsWithOutputType(OUTPUT_TYPE);
channelsFuture.complete(channels);
} catch (Exception e) {
log.error("Failed to load output channels for {}", OUTPUT_TYPE, e);
channelsFuture.complete(new ArrayList<>());
}
});
List<OutputChannel> channels = channelsFuture.join();
if (channels.isEmpty()) {
log.warn("No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE);
return;
}
// Build embed content
List<DiscordSender.EmbedField> fields = new ArrayList<>();
fields.add(new DiscordSender.EmbedField(
"Applicant",
"UUID: " + safe(String.valueOf(application.uuid())) + "\n" +
"Discord: " + safe(application.discordUsername()) + "\n" +
"Email: " + safe(application.email()) + "\n" +
"Age: " + safe(String.valueOf(application.age())) + "\n" +
"Meets reqs: " + (application.meetsRequirements() != null && application.meetsRequirements()),
false
));
fields.add(new DiscordSender.EmbedField(
"Availability",
"Days: " + safe(application.availableDays()) + "\n" +
"Times: " + safe(application.availableTimes()),
false
));
fields.add(new DiscordSender.EmbedField(
"Experience",
"Previous: " + safe(application.previousExperience()) + "\n" +
"Plugins: " + safe(application.pluginExperience()) + "\n" +
"Expectations: " + safe(application.moderatorExpectations()),
false
));
if (application.additionalInfo() != null && !application.additionalInfo().isBlank()) {
fields.add(new DiscordSender.EmbedField(
"Additional Info",
application.additionalInfo(),
false
));
}
List<Long> channelIds = channels.stream()
.map(OutputChannel::channel)
.toList();
Instant timestamp = application.createdAt() != null ? application.createdAt() : Instant.now();
DiscordSender.getInstance().sendEmbedToChannels(
channelIds,
"New Staff Application Submitted",
"Join date: " + (application.joinDate() != null ? application.joinDate().toString() : "unknown") +
"\nSubmitted: " + formatInstant(timestamp),
fields,
null,
timestamp,
null
);
}
private String safe(String s) {
return s == null ? "unknown" : s;
}
private String formatInstant(Instant instant) {
if (instant == null) return "unknown";
return instant.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
}
}

View File

@ -0,0 +1,71 @@
package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@Slf4j
@Service
@RequiredArgsConstructor
public class StaffApplicationMail {
private final JavaMailSender mailSender;
private final SpringTemplateEngine templateEngine;
@Value("${spring.mail.username}")
private String fromEmail;
private static final String STAFF_APPLICATION_EMAIL = "staff@alttd.com";
/**
* Sends an email with the staff application details to the staff applications team mailbox.
* Returns true if the email was sent successfully.
*/
public boolean sendApplicationEmail(StaffApplication application) {
try {
doSend(application);
log.info("Staff application email sent successfully for application ID: {}", application.id());
return true;
} catch (Exception e) {
log.error("Failed to send staff application email for application ID: {}", application.id(), e);
return false;
}
}
private void doSend(StaffApplication application) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(STAFF_APPLICATION_EMAIL);
helper.setReplyTo(application.email());
helper.setSubject("New Staff Application - " + safe(application.discordUsername()));
// Prepare template context
String createdAt = application.createdAt()
.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'"));
Context context = new Context();
context.setVariable("application", application);
context.setVariable("createdAt", createdAt);
String content = templateEngine.process("staff-application-email", context);
helper.setText(content, true);
mailSender.send(message);
}
private String safe(String s) {
return s == null ? "unknown" : s;
}
}

View File

@ -0,0 +1,138 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<meta charset="UTF-8">
<title>Staff Application</title>
<style>
@font-face {
font-family: 'minecraft-title';
src: url('https://beta.alttd.com/public/fonts/minecraft-title.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/minecraft-title.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/minecraft-title.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/minecraft-title.woff') format('woff');
}
@font-face {
font-family: 'minecraft-text';
src: url('https://beta.alttd.com/public/fonts/minecraft-text.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/minecraft-text.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/minecraft-text.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/minecraft-text.woff') format('woff');
}
@font-face {
font-family: 'opensans';
src: url('https://beta.alttd.com/public/fonts/opensans.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/opensans.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/opensans.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/opensans.woff') format('woff');
}
@font-face {
font-family: 'opensans-bold';
src: url('https://beta.alttd.com/public/fonts/opensans-bold.ttf') format('truetype'),
url('https://beta.alttd.com/public/fonts/opensans-bold.eot') format('embedded-opentype'),
url('https://beta.alttd.com/public/fonts/opensans-bold.svg') format('svg'),
url('https://beta.alttd.com/public/fonts/opensans-bold.woff') format('woff');
}
body {
font-family: 'minecraft-title', sans-serif;
}
.columnSection {
width: 80%;
max-width: 800px;
margin: 0 auto;
display: flex;
}
.columnContainer {
flex: 1 1 200px;
min-width: 200px;
box-sizing: border-box;
padding: 0 15px;
}
img {
display: block;
margin: auto;
padding-top: 10px;
}
ul {
list-style-type: none;
padding-left: 0;
}
li {
padding-bottom: 7px;
}
li, p {
font-family: 'opensans', sans-serif;
font-size: 1rem;
}
h2 {
font-size: 1.5rem;
}
@media (max-width: 1150px) {
.columnContainer, .columnSection {
width: 90%;
}
}
@media (max-width: 690px) {
.columnContainer {
width: 100%;
text-align: center;
}
}
</style>
</head>
<body>
<main>
<img id="header-img" src="https://beta.alttd.com/public/img/logos/logo.png" alt="The Altitude Minecraft Server" height="159" width="275">
<h1 style="text-align: center;" th:text="'Staff application by ' + ${application.discordUsername}">Staff application</h1>
<section class="columnSection">
<div class="columnContainer">
<div>
<h2>Applicant</h2>
<ul>
<li><strong>UUID:</strong> <span th:text="${application.uuid}">uuid</span></li>
<li><strong>Email:</strong> <span th:text="${application.email}">email</span></li>
<li><strong>Discord:</strong> <span th:text="${application.discordUsername}">discord</span></li>
<li><strong>Age:</strong> <span th:text="${application.age}">age</span></li>
<li><strong>Pronouns:</strong> <span th:text="${application.pronouns}">pronouns</span></li>
<li><strong>Join date:</strong> <span th:text="${application.joinDate}">date</span></li>
<li><strong>Submitted at:</strong> <span th:text="${createdAt}">date</span></li>
</ul>
</div>
</div>
<div class="columnContainer">
<div>
<h2>Availability</h2>
<ul>
<li><strong>Days:</strong> <span th:text="${application.availableDays}">days</span></li>
<li><strong>Times:</strong> <span th:text="${application.availableTimes}">times</span></li>
<li><strong>Weekly playtime:</strong> <span th:text="${application.weeklyPlaytime}">0</span> hours</li>
</ul>
<h2>Experience</h2>
<p><strong>Previous:</strong><br><span th:text="${application.previousExperience}">previous experience</span></p>
<p><strong>Plugins:</strong><br><span th:text="${application.pluginExperience}">plugin experience</span></p>
<p><strong>Expectations:</strong><br><span th:text="${application.moderatorExpectations}">moderator expectations</span></p>
<div th:if="${application.additionalInfo} != null and ${application.additionalInfo} != ''">
<h2>Additional info</h2>
<p th:text="${application.additionalInfo}">additional info</p>
</div>
</div>
</div>
</section>
</main>
</body>
</html>