Compare commits

...

2 Commits

8 changed files with 567 additions and 3 deletions

View File

@ -1,15 +1,123 @@
package com.alttd.altitudeweb.controllers.forms;
import com.alttd.altitudeweb.api.ApplicationsApi;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
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 org.springframework.http.HttpStatusCode;
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;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@Slf4j
@RestController
@AllArgsConstructor
@RateLimit(limit = 30, timeValue = 1, timeUnit = TimeUnit.HOURS)
public class ApplicationController implements ApplicationsApi {
private final StaffApplicationDataMapper staffApplicationDataMapper;
private final StaffApplicationMail staffApplicationMail;
private final StaffApplicationDiscord staffApplicationDiscord;
@Override
public ResponseEntity<FormResponseDto> submitStaffApplication(StaffApplicationDto staffApplicationDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Staff applications are not yet supported");
UUID userUuid = AuthenticatedUuid.getAuthenticatedUserUuid();
StaffApplication application = staffApplicationDataMapper.map(userUuid, staffApplicationDto);
saveApplication(application);
Optional<EmailVerification> optionalEmail = fetchEmailVerification(userUuid, application.email());
boolean verified = optionalEmail.map(EmailVerification::verified).orElse(false);
boolean success = true;
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;
}
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);
return ResponseEntity.status(201).body(response);
}
private void saveApplication(StaffApplication application) {
CompletableFuture<Void> saveFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
try {
sqlSession.getMapper(StaffApplicationMapper.class).insert(application);
saveFuture.complete(null);
} catch (Exception e) {
log.error("Failed to insert staff application", e);
saveFuture.completeExceptionally(e);
}
});
saveFuture.join();
}
private Optional<EmailVerification> fetchEmailVerification(UUID userUuid, String email) {
CompletableFuture<Optional<EmailVerification>> emailVerificationFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
.findByUserAndEmail(userUuid, email);
emailVerificationFuture.complete(Optional.ofNullable(verifiedMail));
});
return emailVerificationFuture.join();
}
private void markAsSent(UUID applicationId) {
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> sqlSession.getMapper(StaffApplicationMapper.class).markAsSent(applicationId));
}
private FormResponseDto buildResponse(StaffApplication application, boolean verified) {
String message = verified
? "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(
application.id().toString(),
message,
true
);
}
}

View File

@ -0,0 +1,53 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplication;
import com.alttd.altitudeweb.model.StaffApplicationDto;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
@Service
public class StaffApplicationDataMapper {
/**
* Maps the incoming DTO and the authenticated user's UUID to a StaffApplication entity.
* Normalizes and prepares fields as needed (lowercase email, join availableDays, timestamps, ids).
*/
public StaffApplication map(UUID userUuid, StaffApplicationDto dto) {
String email = dto.getEmail() == null ? null : dto.getEmail().toLowerCase();
String availableDaysJoined = joinList(dto.getAvailableDays());
return new StaffApplication(
UUID.randomUUID(),
userUuid,
email,
dto.getAge(),
dto.getDiscordUsername(),
Boolean.TRUE.equals(dto.getMeetsRequirements()),
dto.getPronouns(),
dto.getJoinDate(),
dto.getWeeklyPlaytime(),
availableDaysJoined,
dto.getAvailableTimes(),
dto.getPreviousExperience(),
dto.getPluginExperience(),
dto.getModeratorExpectations(),
dto.getAdditionalInfo(),
Instant.now(),
null,
null
);
}
private String joinList(List<String> list) {
if (list == null) return null;
// Avoid NPEs and trim entries
return list.stream()
.filter(s -> s != null && !s.isBlank())
.map(String::trim)
.collect(Collectors.joining(","));
}
}

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>

View File

@ -0,0 +1,28 @@
package com.alttd.altitudeweb.database.web_db.forms;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
public record StaffApplication(
UUID id,
UUID uuid,
String email,
Integer age,
String discordUsername,
Boolean meetsRequirements,
String pronouns,
LocalDate joinDate,
Integer weeklyPlaytime,
String availableDays,
String availableTimes,
String previousExperience,
String pluginExperience,
String moderatorExpectations,
String additionalInfo,
Instant createdAt,
Instant sendAt,
Long assignedTo
) {
}

View File

@ -0,0 +1,29 @@
package com.alttd.altitudeweb.database.web_db.forms;
import org.apache.ibatis.annotations.*;
import java.util.UUID;
public interface StaffApplicationMapper {
@Insert("""
INSERT INTO staff_applications (
id, uuid, email, age, discord_username, meets_requirements, pronouns, join_date,
weekly_playtime, available_days, available_times, previous_experience, plugin_experience,
moderator_expectations, additional_info, created_at, send_at, assigned_to
) VALUES (
#{id}, #{uuid}, #{email}, #{age}, #{discordUsername}, #{meetsRequirements}, #{pronouns}, #{joinDate},
#{weeklyPlaytime},
#{availableDays},
#{availableTimes}, #{previousExperience}, #{pluginExperience},
#{moderatorExpectations}, #{additionalInfo}, #{createdAt}, #{sendAt}, #{assignedTo}
)
""")
void insert(StaffApplication application);
@Update("""
UPDATE staff_applications SET send_at = NOW()
WHERE id = #{id}
""")
void markAsSent(@Param("id") UUID id);
}

View File

@ -5,6 +5,7 @@ import com.alttd.altitudeweb.database.web_db.KeyPairMapper;
import com.alttd.altitudeweb.database.web_db.PrivilegedUserMapper;
import com.alttd.altitudeweb.database.web_db.SettingsMapper;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.forms.StaffApplicationMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
@ -23,6 +24,7 @@ public class InitializeWebDb {
configuration.addMapper(KeyPairMapper.class);
configuration.addMapper(PrivilegedUserMapper.class);
configuration.addMapper(AppealMapper.class);
configuration.addMapper(StaffApplicationMapper.class);
configuration.addMapper(EmailVerificationMapper.class);
}).join()
.runQuery(sqlSession -> {
@ -31,6 +33,7 @@ public class InitializeWebDb {
createPrivilegedUsersTable(sqlSession);
createPrivilegesTable(sqlSession);
createAppealTable(sqlSession);
createStaffApplicationsTable(sqlSession);
createUserEmailsTable(sqlSession);
});
log.debug("Initialized WebDb");
@ -126,6 +129,37 @@ public class InitializeWebDb {
}
}
private static void createStaffApplicationsTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS staff_applications (
id UUID NOT NULL DEFAULT (UUID()) PRIMARY KEY,
uuid UUID NOT NULL,
email VARCHAR(320) NOT NULL,
age INT NOT NULL,
discord_username VARCHAR(32) NOT NULL,
meets_requirements BOOLEAN NOT NULL,
pronouns VARCHAR(32) NULL,
join_date DATE NOT NULL,
weekly_playtime INT NOT NULL,
available_days TEXT NOT NULL,
available_times TEXT NOT NULL,
previous_experience TEXT NOT NULL,
plugin_experience TEXT NOT NULL,
moderator_expectations TEXT NOT NULL,
additional_info TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
send_at TIMESTAMP NULL,
assigned_to BIGINT UNSIGNED NULL,
FOREIGN KEY (uuid) REFERENCES privileged_users(uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
""";
try (Statement statement = sqlSession.getConnection().createStatement()) {
statement.execute(query);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private static void createAppealTable(@NotNull SqlSession sqlSession) {
String query = """
CREATE TABLE IF NOT EXISTS appeals (