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
@@ -52,6 +52,7 @@ public class SecurityConfig {
.requestMatchers("/api/login/getUsername").authenticated()
.requestMatchers("/api/mail/**").authenticated()
.requestMatchers("/api/site/vote").authenticated()
.requestMatchers("/api/appeal").authenticated()
.requestMatchers("/api/site/get-staff-playtime/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/head_mod/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
.requestMatchers("/api/particles/**").hasAuthority(PermissionClaimDto.HEAD_MOD.getValue())
@@ -10,15 +10,14 @@ 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.DiscordAppealDto;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.model.MinecraftAppealDto;
import com.alttd.altitudeweb.model.UpdateMailDto;
import com.alttd.altitudeweb.model.*;
import com.alttd.altitudeweb.services.forms.DiscordAppeal;
import com.alttd.altitudeweb.services.limits.RateLimit;
import com.alttd.altitudeweb.services.mail.AppealMail;
import com.alttd.altitudeweb.setup.Connection;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;
@@ -36,12 +35,18 @@ public class AppealController implements AppealsApi {
private final AppealDataMapper mapper;
private final AppealMail appealMail;
private final DiscordAppeal discordAppeal;
private final com.alttd.altitudeweb.services.discord.AppealDiscord appealDiscord;
@Override
public ResponseEntity<BannedUserResponseDto> getBannedUser(Long discordId) throws Exception {
return new ResponseEntity<>(discordAppeal.getBannedUser(discordId), HttpStatus.OK);
}
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "discordAppeal")
@Override
public ResponseEntity<FormResponseDto> submitDiscordAppeal(DiscordAppealDto discordAppealDto) {
throw new ResponseStatusException(HttpStatusCode.valueOf(501), "Discord appeals are not yet supported");
return new ResponseEntity<>(discordAppeal.submitAppeal(discordAppealDto), HttpStatus.OK);
}
@RateLimit(limit = 3, timeValue = 1, timeUnit = TimeUnit.HOURS, key = "minecraftAppeal")
@@ -0,0 +1,14 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.model.BannedUserDto;
import com.alttd.webinterface.appeals.BannedUser;
import org.springframework.stereotype.Service;
@Service
public class BannedUserToBannedUserDtoMapper {
public BannedUserDto map(BannedUser bannedUser) {
return new BannedUserDto(bannedUser.userId(), bannedUser.reason(), bannedUser.name(), bannedUser.avatarUrl());
}
}
@@ -0,0 +1,26 @@
package com.alttd.altitudeweb.mappers;
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal;
import com.alttd.altitudeweb.model.DiscordAppealDto;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.UUID;
@Service
public class DiscordAppealDtoToDiscordAppealMapper {
public DiscordAppeal map(DiscordAppealDto discordAppealDto, UUID loggedInUserUuid, String discordUsername) {
return new DiscordAppeal(
UUID.randomUUID(),
loggedInUserUuid,
discordAppealDto.getDiscordId(),
discordUsername,
discordAppealDto.getAppeal(),
Instant.now(),
null,
discordAppealDto.getEmail(),
null);
}
}
@@ -11,6 +11,8 @@ import com.alttd.altitudeweb.database.litebans.HistoryType;
import com.alttd.altitudeweb.database.litebans.UserType;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.database.web_db.forms.AppealMapper;
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal;
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppealMapper;
import com.alttd.altitudeweb.setup.Connection;
import com.alttd.webinterface.appeals.AppealSender;
import com.alttd.webinterface.objects.MessageForEmbed;
@@ -33,19 +35,67 @@ public class AppealDiscord {
private static final String OUTPUT_TYPE = "APPEAL";
public void sendAppealToDiscord(DiscordAppeal discordAppeal) {
CompletableFuture<List<OutputChannel>> channelsFuture = getChannelListFuture();
List<OutputChannel> channels = channelsFuture.join();
if (channels.isEmpty()) {
log.warn("Discord appeal: No Discord output channels found for type {}. Skipping Discord send.", OUTPUT_TYPE);
return;
}
String createdAt = formatInstant(discordAppeal.createdAt());
List<DiscordSender.EmbedField> fields = new ArrayList<>();
// Group: User
fields.add(new DiscordSender.EmbedField(
"User",
"""
Discord Username: %s
Discord id: %s
MC UUID: %s
Submitted: %s
""".formatted(
safe(discordAppeal.username()),
discordAppeal.discordId(),
safe(String.valueOf(discordAppeal.uuid())),
createdAt
),
false
));
Optional<Long> optionalAssignedTo = assignAppeal();
if (optionalAssignedTo.isPresent()) {
Long assignedTo = optionalAssignedTo.get();
fields.add(new DiscordSender.EmbedField(
"Assigned to",
"Assigned to: <@" + assignedTo + ">",
true
));
assignDiscordAppealTo(discordAppeal.id(), assignedTo);
} else {
fields.add(new DiscordSender.EmbedField(
"Assigned to",
"Assigned to: None (failed to assign)",
true
));
}
String description = safe(discordAppeal.reason());
List<Long> channelIds = channels.stream()
.map(OutputChannel::channel)
.toList();
// colorRgb = null (use default), timestamp = appeal.createdAt if available
Instant timestamp = discordAppeal.createdAt() != null ? discordAppeal.createdAt() : Instant.now();
MessageForEmbed newAppealSubmitted = new MessageForEmbed(
"New Discord Appeal Submitted", description, fields, null, timestamp, null);
AppealSender.getInstance().sendAppeal(channelIds, newAppealSubmitted, optionalAssignedTo.orElse(0L));
}
public void sendAppealToDiscord(Appeal appeal, HistoryRecord history) {
// Fetch channels
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<>());
}
});
CompletableFuture<List<OutputChannel>> channelsFuture = getChannelListFuture();
CompletableFuture<Integer> bansF = getCountAsync(HistoryType.BAN, appeal.uuid());
CompletableFuture<Integer> mutesF = getCountAsync(HistoryType.MUTE, appeal.uuid());
@@ -102,7 +152,7 @@ public class AppealDiscord {
"Assigned to: <@" + assignedTo + ">",
true
));
assignAppealTo(appeal.id(), assignedTo);
assignMinecraftAppealTo(appeal.id(), assignedTo);
} else {
fields.add(new DiscordSender.EmbedField(
"Assigned to",
@@ -124,7 +174,22 @@ public class AppealDiscord {
AppealSender.getInstance().sendAppeal(channelIds, newAppealSubmitted, optionalAssignedTo.orElse(0L));
}
private void assignAppealTo(UUID appealId, Long assignedTo) {
private static CompletableFuture<List<OutputChannel>> getChannelListFuture() {
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<>());
}
});
return channelsFuture;
}
private void assignMinecraftAppealTo(UUID appealId, Long assignedTo) {
Connection.getConnection(Databases.DEFAULT).runQuery(sql -> {
try {
sql.getMapper(AppealMapper.class).assignAppeal(appealId, assignedTo);
@@ -134,6 +199,16 @@ public class AppealDiscord {
});
}
private void assignDiscordAppealTo(UUID appealId, Long assignedTo) {
Connection.getConnection(Databases.DEFAULT).runQuery(sql -> {
try {
sql.getMapper(DiscordAppealMapper.class).assignDiscordAppeal(appealId, assignedTo);
} catch (Exception e) {
log.error("Failed to assign appeal to {}", assignedTo, e);
}
});
}
private CompletableFuture<Integer> getCountAsync(HistoryType type, java.util.UUID uuid) {
CompletableFuture<Integer> future = new CompletableFuture<>();
Connection.getConnection(Databases.LITE_BANS).runQuery(sql -> {
@@ -0,0 +1,119 @@
package com.alttd.altitudeweb.services.forms;
import com.alttd.altitudeweb.controllers.data_from_auth.AuthenticatedUuid;
import com.alttd.altitudeweb.database.Databases;
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppealMapper;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerification;
import com.alttd.altitudeweb.database.web_db.mail.EmailVerificationMapper;
import com.alttd.altitudeweb.mappers.BannedUserToBannedUserDtoMapper;
import com.alttd.altitudeweb.mappers.DiscordAppealDtoToDiscordAppealMapper;
import com.alttd.altitudeweb.model.BannedUserResponseDto;
import com.alttd.altitudeweb.model.DiscordAppealDto;
import com.alttd.altitudeweb.model.FormResponseDto;
import com.alttd.altitudeweb.services.discord.AppealDiscord;
import com.alttd.altitudeweb.services.mail.AppealMail;
import com.alttd.altitudeweb.setup.Connection;
import com.alttd.webinterface.appeals.BannedUser;
import com.alttd.webinterface.appeals.DiscordAppealDiscord;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
@RequiredArgsConstructor
public class DiscordAppeal {
private final BannedUserToBannedUserDtoMapper bannedUserToBannedUserDtoMapper;
private final DiscordAppealDtoToDiscordAppealMapper discordAppealDtoToDiscordAppealMapper;
private final AuthenticatedUuid authenticatedUuid;
private final AppealDiscord appealDiscord;
private final AppealMail appealMail;
public BannedUserResponseDto getBannedUser(Long discordId) {
DiscordAppealDiscord discordAppeal = DiscordAppealDiscord.getInstance();
Optional<BannedUser> join = discordAppeal.getBannedUser(discordId).join();
if (join.isEmpty()) {
return new BannedUserResponseDto(false);
}
BannedUserResponseDto bannedUserResponseDto = new BannedUserResponseDto(true);
bannedUserResponseDto.setBannedUser(bannedUserToBannedUserDtoMapper.map(join.get()));
return bannedUserResponseDto;
}
public FormResponseDto submitAppeal(DiscordAppealDto discordAppealDto) {
DiscordAppealDiscord discordAppealDiscord = DiscordAppealDiscord.getInstance();
Optional<BannedUser> join = discordAppealDiscord.getBannedUser(discordAppealDto.getDiscordId()).join();
if (join.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
}
BannedUser bannedUser = join.get();
Optional<UUID> uuid = authenticatedUuid.tryGetAuthenticatedUserUuid();
if (uuid.isEmpty()) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not authenticated");
}
CompletableFuture<com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal> appealCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Loading history by id");
try {
com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal discordAppealRecord = discordAppealDtoToDiscordAppealMapper
.map(discordAppealDto, uuid.get(), bannedUser.name());
sqlSession.getMapper(DiscordAppealMapper.class).createDiscordAppeal(discordAppealRecord);
appealCompletableFuture.complete(discordAppealRecord);
} catch (Exception e) {
log.error("Failed to load history count", e);
appealCompletableFuture.completeExceptionally(e);
}
});
com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal discordAppeal = appealCompletableFuture.join();
CompletableFuture<Optional<EmailVerification>> emailVerificationCompletableFuture = new CompletableFuture<>();
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Retrieving mail by uuid and address");
EmailVerification verifiedMail = sqlSession.getMapper(EmailVerificationMapper.class)
.findByUserAndEmail(discordAppeal.uuid(), discordAppeal.email().toLowerCase());
emailVerificationCompletableFuture.complete(Optional.ofNullable(verifiedMail));
});
Optional<EmailVerification> optionalEmailVerification = emailVerificationCompletableFuture.join();
if (optionalEmailVerification.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid mail");
}
EmailVerification emailVerification = optionalEmailVerification.get();
if (!emailVerification.verified()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Mail not verified");
}
try {
appealDiscord.sendAppealToDiscord(discordAppeal);
} catch (Exception e) {
log.error("Failed to send appeal {} to Discord", discordAppeal.id(), e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to send appeal to Discord");
}
//TODO verify mail
appealMail.sendAppealNotification(discordAppeal);
Connection.getConnection(Databases.DEFAULT)
.runQuery(sqlSession -> {
log.debug("Marking appeal {} as sent", discordAppeal.id());
sqlSession.getMapper(DiscordAppealMapper.class)
.markDiscordAppealAsSent(discordAppeal.id());
});
return new FormResponseDto(
discordAppeal.id().toString(),
"Your appeal has been submitted. You will be notified when it has been reviewed.",
true);
}
}
@@ -2,6 +2,7 @@ package com.alttd.altitudeweb.services.mail;
import com.alttd.altitudeweb.database.litebans.HistoryRecord;
import com.alttd.altitudeweb.database.web_db.forms.Appeal;
import com.alttd.altitudeweb.database.web_db.forms.DiscordAppeal;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
@@ -29,6 +30,20 @@ public class AppealMail {
private static final String APPEAL_EMAIL = "[email protected]";
/**
* Sends an email notification about the appeal to both the user and the appeals team.
*
* @param appeal The appeal object containing all necessary information
*/
public void sendAppealNotification(DiscordAppeal appeal) {
try {
sendEmailToAppealsTeam(appeal);
log.info("Discord Appeal notification emails sent successfully for appeal ID: {}", appeal.id());
} catch (Exception e) {
log.error("Failed to send discord appeal notification emails for appeal ID: {}", appeal.id(), e);
}
}
/**
* Sends an email notification about the appeal to both the user and the appeals team.
*
@@ -66,4 +81,29 @@ public class AppealMail {
mailSender.send(message);
}
private void sendEmailToAppealsTeam(DiscordAppeal appeal) throws MessagingException {
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = getAppealMimeMessageHelper(appeal, message);
Context context = new Context();
context.setVariable("appeal", appeal);
context.setVariable("createdAt", appeal.createdAt()
.atZone(ZoneId.of("UTC"))
.format(DateTimeFormatter.ofPattern("yyyy MMMM dd hh:mm a '(UTC)'")));
String content = templateEngine.process("discord-appeal-email", context);
helper.setText(content, true);
mailSender.send(message);
}
private MimeMessageHelper getAppealMimeMessageHelper(DiscordAppeal appeal, MimeMessage message) throws MessagingException {
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setFrom(fromEmail);
helper.setTo(APPEAL_EMAIL);
helper.setReplyTo(appeal.email());
helper.setSubject("New Appeal Submitted - " + appeal.username());
return helper;
}
}