From a114e30efd36a75e5d5b528d7f358971a84e7e0a Mon Sep 17 00:00:00 2001 From: Defective Date: Mon, 8 Sep 2025 01:13:50 +0200 Subject: [PATCH 01/14] Forms system A command to attach form buttons to bot's messages Form data deserialization Store forms in database Use an array of form fields instead of a form data object Move `FormsRepository` and `FormField` to different packages Reading forms from database Open modals on form button interaction Log submissions in a configured channel Allow setting a custom submission message Use an *Empty* value if user ommits a field Ignore case when parsing form field text input styles Store forms' origin messages, their channels, and expiration date Form delete command autocompletion Form delete command Forms closing and reopening commands Form details command Form modification command Check if the form is not closed or expired on submission Reword expiration command parameter description Additional checks in all form commands Don't accept raw JSON data and don't immediately attach forms Added `add-field` form command Form field remove command Form show command Put a limit of 5 components for forms Allow field inserting Forms attach and detach commands Prevent removing the last field from an attached form Javadocs Remove redundant checks from form commands Move some common methods to the form interaction manager Added `hasExpired` method to `FormData` Add `onetime` form parameter Logging form submissions to the database Add a repository method to query forms for a specific `closed` state Include total submissions count in the form details message Additional null checks in the forms repository Prevent further submissions to one-time forms A command to export all form submissions Delete form submissions on form deletion Command to delete user's submissions Fix checkstyle violations --- .../forms/FormInteractionManager.java | 275 +++++++++++++++ .../commands/AddFieldFormSubcommand.java | 103 ++++++ .../forms/commands/AttachFormSubcommand.java | 148 ++++++++ .../forms/commands/CloseFormSubcommand.java | 71 ++++ .../forms/commands/CreateFormSubcommand.java | 64 ++++ .../forms/commands/DeleteFormSubcommand.java | 64 ++++ .../forms/commands/DetachFormSubcommand.java | 105 ++++++ .../forms/commands/DetailsFormSubcommand.java | 105 ++++++ .../forms/commands/FormCommand.java | 40 +++ .../forms/commands/ModifyFormSubcommand.java | 89 +++++ .../commands/RemoveFieldFormSubcommand.java | 93 +++++ .../forms/commands/ReopenFormSubcommand.java | 71 ++++ .../forms/commands/ShowFormSubcommand.java | 59 ++++ .../SubmissionsDeleteFormSubcommand.java | 77 +++++ .../SubmissionsExportFormSubcommand.java | 81 +++++ .../forms/dao/FormsRepository.java | 327 ++++++++++++++++++ .../staff_commands/forms/model/FormData.java | 161 +++++++++ .../staff_commands/forms/model/FormField.java | 95 +++++ .../staff_commands/forms/model/FormUser.java | 44 +++ .../database/migrations/09-08-2025_forms.sql | 23 ++ 20 files changed, 2095 insertions(+) create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java create mode 100644 src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java create mode 100644 src/main/resources/database/migrations/09-08-2025_forms.sql diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java new file mode 100644 index 000000000..32395d3da --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -0,0 +1,275 @@ +package net.discordjug.javabot.systems.staff_commands.forms; + +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.TimeZone; +import java.util.function.Function; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.annotations.AutoDetectableComponentHandler; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.ItemComponent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.interactions.modals.Modal; +import net.dv8tion.jda.api.interactions.modals.ModalMapping; +import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler; +import xyz.dynxsty.dih4jda.interactions.components.ModalHandler; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * Handle forms interactions, including buttons and submissions modals. + */ +@AutoDetectableComponentHandler(FormInteractionManager.FORM_COMPONENT_ID) +@RequiredArgsConstructor +public class FormInteractionManager implements ButtonHandler, ModalHandler { + + /** + * Date and time format used in forms. + */ + public static final DateFormat DATE_FORMAT; + + /** + * String representation of the date and time format used in forms. + */ + public static final String DATE_FORMAT_STRING; + + /** + * Component ID used for form buttons and modals. + */ + public static final String FORM_COMPONENT_ID = "modal-form"; + private static final String FORM_NOT_FOUND_MSG = "This form was not found in the database. Please report this to the server staff."; + + private final FormsRepository formsRepo; + + static { + DATE_FORMAT_STRING = "dd/MM/yyyy HH:mm"; + DATE_FORMAT = new SimpleDateFormat(FormInteractionManager.DATE_FORMAT_STRING, Locale.ENGLISH); + DATE_FORMAT.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + /** + * Closes the form, preventing further submissions and disabling associated + * buttons from a message this form is attached to, if any. + * + * @param guild guild this form is located in. + * @param form form to close. + */ + public void closeForm(Guild guild, FormData form) { + formsRepo.closeForm(form); + + if (form.getMessageChannel() != null && form.getMessageId() != null) { + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); + formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + mapFormMessageButtons(msg, btn -> { + String cptId = btn.getId(); + String[] split = ComponentIdBuilder.split(cptId); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) + && split[1].equals(Long.toString(form.getId()))) { + return btn.asDisabled(); + } + return btn; + }); + }, t -> {}); + } + } + + @Override + public void handleButton(ButtonInteractionEvent event, Button button) { + long formId = Long.parseLong(ComponentIdBuilder.split(button.getId())[1]); + Optional formOpt = formsRepo.getForm(formId); + if (!formOpt.isPresent()) { + event.reply(FORM_NOT_FOUND_MSG).setEphemeral(true).queue(); + return; + } + FormData form = formOpt.get(); + if (!checkNotClosed(form)) { + event.reply("This form is not accepting new submissions.").setEphemeral(true).queue(); + if (!form.isClosed()) { + closeForm(event.getGuild(), form); + } + return; + } + + if (form.isOnetime() && formsRepo.hasSubmitted(event.getUser(), form)) { + event.reply("You have already submitted this form").setEphemeral(true).queue(); + return; + } + + Modal modal = createFormModal(form); + + event.replyModal(modal).queue(); + } + + @Override + public void handleModal(ModalInteractionEvent event, List values) { + event.deferReply().setEphemeral(true).queue(); + long formId = Long.parseLong(ComponentIdBuilder.split(event.getModalId())[1]); + Optional formOpt = formsRepo.getForm(formId); + if (!formOpt.isPresent()) { + event.reply(FORM_NOT_FOUND_MSG).setEphemeral(true).queue(); + return; + } + + FormData form = formOpt.get(); + + if (!checkNotClosed(form)) { + event.getHook().sendMessage("This form is not accepting new submissions.").queue(); + return; + } + + if (form.isOnetime() && formsRepo.hasSubmitted(event.getUser(), form)) { + event.getHook().sendMessage("You have already submitted this form").queue(); + return; + } + + TextChannel channel = event.getGuild().getTextChannelById(form.getSubmitChannel()); + if (channel == null) { + event.getHook() + .sendMessage("We couldn't receive your submission due to an error. Please contact server staff.") + .queue(); + return; + } + + channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(); + formsRepo.logSubmission(event.getUser(), form); + + event.getHook() + .sendMessage( + form.getSubmitMessage() == null ? "Your submission was received!" : form.getSubmitMessage()) + .queue(); + } + + /** + * Modifies buttons in a message using given function for mapping. + * + * @param msg message to modify buttons in. + * @param mapper mapping function. + */ + public void mapFormMessageButtons(Message msg, Function mapper) { + List components = msg.getActionRows().stream().map(row -> { + ItemComponent[] cpts = row.getComponents().stream().map(cpt -> { + if (cpt instanceof Button btn) { + return mapper.apply(btn); + } + return cpt; + }).toList().toArray(new ItemComponent[0]); + if (cpts.length == 0) { + return null; + } + return ActionRow.of(cpts); + }).filter(Objects::nonNull).toList(); + msg.editMessageComponents(components).queue(); + } + + /** + * Re-opens the form, re-enabling associated buttons in message it's attached + * to, if any. + * + * @param guild guild this form is contained in. + * @param form form to re-open. + */ + public void reopenForm(Guild guild, FormData form) { + formsRepo.reopenForm(form); + + if (form.getMessageChannel() != null && form.getMessageId() != null) { + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); + formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + mapFormMessageButtons(msg, btn -> { + String cptId = btn.getId(); + String[] split = ComponentIdBuilder.split(cptId); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) + && split[1].equals(Long.toString(form.getId()))) { + return btn.asEnabled(); + } + return btn; + }); + }, t -> {}); + } + } + + /** + * Creates a submission modal for the given form. + * + * @param form form to open submission modal for. + * @return submission modal to be presented to the user. + */ + public static Modal createFormModal(FormData form) { + Modal modal = Modal.create(ComponentIdBuilder.build(FORM_COMPONENT_ID, form.getId()), form.getTitle()) + .addComponents(form.createComponents()).build(); + return modal; + } + + /** + * Gets expiration time from the slash comamnd event. + * + * @param event slash event to get expiration from. + * @return an optional containing expiration time, + * {@link FormData#EXPIRATION_PERMANENT} if none given, or an empty + * optional if it's invalid. + */ + public static Optional parseExpiration(SlashCommandInteractionEvent event) { + String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString); + Optional expiration; + if (expirationStr == null) { + expiration = Optional.of(FormData.EXPIRATION_PERMANENT); + } else { + try { + expiration = Optional.of(FormInteractionManager.DATE_FORMAT.parse(expirationStr).getTime()); + } catch (ParseException e) { + event.getHook().sendMessage("Invalid date. You should follow the format `" + + FormInteractionManager.DATE_FORMAT_STRING + "`.").setEphemeral(true).queue(); + expiration = Optional.empty(); + } + } + + if (expiration.isPresent() && expiration.get() != FormData.EXPIRATION_PERMANENT + && expiration.get() < System.currentTimeMillis()) { + event.getHook().sendMessage("The expiration date shouldn't be in the past").setEphemeral(true).queue(); + return Optional.empty(); + } + return expiration; + } + + private static boolean checkNotClosed(FormData data) { + if (data.isClosed() || data.hasExpired()) { + return false; + } + + return true; + } + + private static MessageEmbed createSubmissionEmbed(FormData form, List values, Member author) { + EmbedBuilder builder = new EmbedBuilder().setTitle("New form submission received") + .setAuthor(author.getEffectiveName(), null, author.getEffectiveAvatarUrl()).setTimestamp(Instant.now()); + builder.addField("Sender", author.getAsMention(), true).addField("Title", form.getTitle(), true); + + int len = Math.min(values.size(), form.getFields().size()); + for (int i = 0; i < len; i++) { + ModalMapping mapping = values.get(i); + FormField field = form.getFields().get(i); + String value = mapping.getAsString(); + builder.addField(field.getLabel(), value == null ? "*Empty*" : "```\n" + value + "\n```", false); + } + + return builder.build(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java new file mode 100644 index 000000000..e29885c56 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java @@ -0,0 +1,103 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Arrays; +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form add-field` command. + */ +public class AddFieldFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public AddFieldFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("add-field", "Adds a field to an existing form") + .addOption(OptionType.INTEGER, "form-id", "Form ID to add the field to", true, true) + .addOption(OptionType.STRING, "label", "Field label", true) + .addOption(OptionType.INTEGER, "min", "Minimum number of characters") + .addOption(OptionType.INTEGER, "max", "Maximum number of characters") + .addOption(OptionType.STRING, "placeholder", "Field placeholder") + .addOption(OptionType.BOOLEAN, "required", + "Whether or not the user has to input data in this field. Default: false") + .addOption(OptionType.STRING, "style", "Input style. Default: SHORT", false, true) + .addOption(OptionType.STRING, "value", "Initial field value") + .addOption(OptionType.INTEGER, "index", "Index to insert the field at")); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply(true).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getFields().size() >= 5) { + event.getHook().sendMessage("Can't add more than 5 components to a form").queue(); + return; + } + + int index = event.getOption("index", -1, OptionMapping::getAsInt); + if (index < -1 || index >= form.getFields().size()) { + event.getHook().sendMessage("Field index out of bounds").queue(); + return; + } + + formsRepo.addField(form, createFormFieldFromEvent(event), index); + event.getHook().sendMessage("Added a new field to the form.").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + switch (target.getName()) { + case "form-id" -> event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + case "style" -> + event.replyChoices(Arrays.stream(TextInputStyle.values()).filter(t -> t != TextInputStyle.UNKNOWN) + .map(style -> new Choice(style.name(), style.name())).toList()).queue(); + default -> {} + } + } + + private static FormField createFormFieldFromEvent(SlashCommandInteractionEvent e) { + String label = e.getOption("label", OptionMapping::getAsString); + int min = e.getOption("min", 0, OptionMapping::getAsInt); + int max = e.getOption("max", 64, OptionMapping::getAsInt); + String placeholder = e.getOption("placeholder", OptionMapping::getAsString); + boolean required = e.getOption("required", false, OptionMapping::getAsBoolean); + TextInputStyle style = e.getOption("style", TextInputStyle.SHORT, t -> { + try { + return TextInputStyle.valueOf(t.getAsString().toUpperCase()); + } catch (IllegalArgumentException e2) { + return TextInputStyle.SHORT; + } + }); + String value = e.getOption("value", OptionMapping::getAsString); + + return new FormField(label, max, min, placeholder, required, style.name(), value); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java new file mode 100644 index 000000000..0c31e0ed5 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java @@ -0,0 +1,148 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.ItemComponent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import net.dv8tion.jda.api.interactions.components.buttons.ButtonStyle; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * The `/form attach` command. + */ +public class AttachFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public AttachFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("attach", "Attach a form to a message").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "ID of the form to attach", true, true), + new OptionData(OptionType.STRING, "message-id", "ID of the message to attach the form to", true), + new OptionData(OptionType.CHANNEL, "channel", + "Channel of the message. Required if the message is in a different channel"), + new OptionData(OptionType.STRING, "button-label", "Label of the submit button. Default is \"Submit\""), + new OptionData(OptionType.STRING, "button-style", "Submit button style. Defaults to primary", false, + true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply().setEphemeral(true).queue(); + + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getMessageChannel() != null && form.getMessageId() != null) { + event.getHook() + .sendMessage("The form seems to already be attached to a message. Detach it before continuing.") + .queue(); + return; + } + + if (form.getFields().isEmpty()) { + event.getHook().sendMessage("You can't attach a form with no fields.").queue(); + return; + } + + String messageId = event.getOption("message-id", OptionMapping::getAsString); + GuildChannel channel = event.getOption("channel", event.getChannel().asGuildMessageChannel(), + OptionMapping::getAsChannel); + + if (channel == null) { + event.getHook().sendMessage("A channel with this ID was not found.").setEphemeral(true).queue(); + return; + } + + if (!(channel instanceof MessageChannel msgChannel)) { + event.getHook().sendMessage("You must specify a message channel").setEphemeral(true).queue(); + return; + } + + String buttonLabel = event.getOption("button-label", "Submit", OptionMapping::getAsString); + ButtonStyle style = event.getOption("button-style", ButtonStyle.PRIMARY, t -> { + try { + return ButtonStyle.valueOf(t.getAsString().toUpperCase()); + } catch (IllegalArgumentException e) { + return ButtonStyle.PRIMARY; + } + }); + + msgChannel.retrieveMessageById(messageId).queue(message -> { + attachFormToMessage(message, buttonLabel, style, form); + formsRepo.attachForm(form, msgChannel, message); + event.getHook() + .sendMessage("Successfully attached the form to the [message](" + message.getJumpUrl() + ")!") + .queue(); + }, t -> event.getHook().sendMessage("A message with this ID was not found").queue()); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + switch (target.getName()) { + case "form-id" -> event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + case "button-style" -> event.replyChoices( + Set.of(ButtonStyle.DANGER, ButtonStyle.PRIMARY, ButtonStyle.SECONDARY, ButtonStyle.SUCCESS).stream() + .map(style -> new Choice(style.name(), style.name())).toList()) + .queue(); + default -> {} + } + } + + private static void attachFormToMessage(Message message, String buttonLabel, ButtonStyle style, FormData form) { + List rows = new ArrayList<>(message.getActionRows()); + + Button button = Button.of(style, + ComponentIdBuilder.build(FormInteractionManager.FORM_COMPONENT_ID, form.getId()), buttonLabel); + + if (form.isClosed() || form.hasExpired()) { + button = button.asDisabled(); + } + + if (rows.isEmpty() || rows.get(rows.size() - 1).getActionComponents().size() >= 5) { + rows.add(ActionRow.of(button)); + } else { + ActionRow lastRow = rows.get(rows.size() - 1); + ItemComponent[] components = new ItemComponent[lastRow.getComponents().size() + 1]; + System.arraycopy(lastRow.getComponents().toArray(new ItemComponent[0]), 0, components, 0, + lastRow.getComponents().size()); + components[components.length - 1] = button; + rows.set(rows.size() - 1, ActionRow.of(components)); + } + + message.editMessageComponents(rows.toArray(new ActionRow[0])).queue(); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java new file mode 100644 index 000000000..3c24e0079 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java @@ -0,0 +1,71 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form close` command. + */ +public class CloseFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + private final FormInteractionManager interactionManager; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param interactionManager form interaction manager + * @param botConfig main bot configuration + */ + public CloseFormSubcommand(FormsRepository formsRepo, FormInteractionManager interactionManager, + BotConfig botConfig) { + this.formsRepo = formsRepo; + this.interactionManager = interactionManager; + setCommandData(new SubcommandData("close", "Close an existing form") + .addOptions(new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to close", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + long id = event.getOption("form-id", OptionMapping::getAsLong); + Optional formOpt = formsRepo.getForm(id); + if (formOpt.isEmpty()) { + event.reply("A form with this ID was not found.").setEphemeral(true).queue(); + return; + } + FormData form = formOpt.get(); + + if (form.isClosed()) { + event.reply("This form is already closed").setEphemeral(true).queue(); + return; + } + + event.deferReply(true).queue(); + + interactionManager.closeForm(event.getGuild(), form); + + event.getHook().sendMessage("Form closed!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms(false).stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java new file mode 100644 index 000000000..aab91e071 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java @@ -0,0 +1,64 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.List; +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form create` command. + */ +public class CreateFormSubcommand extends Subcommand { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public CreateFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("create", "Create a new form").addOptions( + new OptionData(OptionType.STRING, "title", "Form title (shown in modal)", true), + new OptionData(OptionType.CHANNEL, "submit-channel", "Channel to log form submissions in", true), + new OptionData(OptionType.STRING, "submit-message", + "Message displayed to the user once they submit the form"), + new OptionData(OptionType.STRING, "expiration", + "UTC time after which the form will not accept further submissions. " + + FormInteractionManager.DATE_FORMAT_STRING), + new OptionData(OptionType.BOOLEAN, "onetime", + "If the form should only accept one submission per user. Defaults to false."))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply().setEphemeral(true).queue(); + String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString); + Optional expirationOpt = FormInteractionManager.parseExpiration(event); + + if (expirationOpt.isEmpty()) return; + + long expiration = expirationOpt.get(); + + long formId = System.currentTimeMillis(); + FormData form = new FormData(formId, List.of(), event.getOption("title", OptionMapping::getAsString), + event.getOption("submit-channel", OptionMapping::getAsChannel).getId(), + event.getOption("submit-message", null, OptionMapping::getAsString), null, null, expiration, false, + event.getOption("onetime", false, OptionMapping::getAsBoolean)); + + formsRepo.insertForm(form); + event.getHook() + .sendMessage("The form was created! Remember to add fields to it before attaching it to a message.") + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java new file mode 100644 index 000000000..a368ab5b4 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java @@ -0,0 +1,64 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form delete` command. + */ +public class DeleteFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public DeleteFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("delete", "Delete an existing form") + .addOptions(new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to delete", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + long id = event.getOption("form-id", OptionMapping::getAsLong); + Optional formOpt = formsRepo.getForm(id); + if (formOpt.isEmpty()) { + event.reply("A form with this ID was not found.").setEphemeral(true).queue(); + return; + } + + event.deferReply(true).queue(); + + FormData form = formOpt.get(); + formsRepo.deleteForm(form); + + if (form.getMessageChannel() != null && form.getMessageId() != null) { + DetachFormSubcommand.detachFromMessage(form, event.getGuild()); + } + + event.getHook().sendMessage("Form deleted!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java new file mode 100644 index 000000000..b40446972 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java @@ -0,0 +1,105 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.ItemComponent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; +import xyz.dynxsty.dih4jda.util.ComponentIdBuilder; + +/** + * The `/form detach` command. + */ +public class DetachFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public DetachFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("detach", "Detach a form from a message") + .addOptions(new OptionData(OptionType.INTEGER, "form-id", "ID of the form to attach", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply().setEphemeral(true).queue(); + + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + + if (form.getMessageChannel() == null && form.getMessageId() == null) { + event.getHook().sendMessage("This form doesn't seem to be attached to a message").queue(); + return; + } + + detachFromMessage(form, event.getGuild()); + formsRepo.detachForm(form); + + event.getHook().sendMessage("Form detached!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } + + /** + * Detaches the form from a message it's attached to, deleting any associated + * buttons. Fails silently if the message was not found. + * + * @param form form to detach + * @param guild guild this form is contained in + */ + public static void detachFromMessage(FormData form, Guild guild) { + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); + formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + List components = msg.getActionRows().stream().map(row -> { + ItemComponent[] cpts = row.getComponents().stream().filter(cpt -> { + if (cpt instanceof Button btn) { + String cptId = btn.getId(); + String[] split = ComponentIdBuilder.split(cptId); + if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID)) { + return !split[1].equals(Long.toString(form.getId())); + } + } + return true; + }).toList().toArray(new ItemComponent[0]); + if (cpts.length == 0) { + return null; + } + return ActionRow.of(cpts); + }).filter(Objects::nonNull).toList(); + msg.editMessageComponents(components).queue(); + }, t -> {}); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java new file mode 100644 index 000000000..008381f56 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java @@ -0,0 +1,105 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.time.Instant; +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form details` command. + */ +public class DetailsFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public DetailsFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("details", "Get details about a form").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get details for", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply().setEphemeral(false).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("Couldn't find a form with this id").queue(); + return; + } + + FormData form = formOpt.get(); + EmbedBuilder embedBuilder = createFormDetailsEmbed(form, event.getGuild()); + embedBuilder.setAuthor(event.getMember().getEffectiveName(), null, event.getMember().getEffectiveAvatarUrl()); + embedBuilder.setTimestamp(Instant.now()); + + MessageCreateData builder = new MessageCreateBuilder().addEmbeds(embedBuilder.build()).build(); + + event.getHook().sendMessage(builder).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } + + private EmbedBuilder createFormDetailsEmbed(FormData form, Guild guild) { + EmbedBuilder builder = new EmbedBuilder().setTitle("Form details"); + + long id = form.getId(); + + addCodeblockField(builder, "ID", id, true); + builder.addField("Created at", String.format("", id / 1000L), true); + + String expiration; + builder.addField("Expires at", + form.hasExpirationTime() ? String.format("", form.getExpiration() / 1000L) : "`Never`", true); + + addCodeblockField(builder, "State", form.isClosed() ? "Closed" : form.hasExpired() ? "Expired" : "Open", false); + + builder.addField("Attached in", + form.getMessageChannel() == null ? "*Not attached*" : "<#" + form.getMessageChannel() + ">", true); + builder.addField("Attached to", + form.getMessageChannel() == null || form.getMessageId() == null ? "*Not attached*" + : String.format("[Link](https://discord.com/channels/%s/%s/%s)", guild.getId(), + form.getMessageChannel(), form.getMessageId()), + true); + + builder.addField("Submissions channel", "<#" + form.getSubmitChannel() + ">", true); + builder.addField("Is one-time", form.isOnetime() ? ":white_check_mark:" : ":x:", true); + addCodeblockField(builder, "Submission message", + form.getSubmitMessage() == null ? "Default" : form.getSubmitMessage(), true); + + addCodeblockField(builder, "Number of fields", form.getFields().size(), true); + addCodeblockField(builder, "Number of submissions", formsRepo.getTotalSubmissionsCount(form), true); + + return builder; + } + + private static void addCodeblockField(EmbedBuilder builder, String name, Object content, boolean inline) { + builder.addField(name, String.format("```\n%s\n```", content), inline); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java new file mode 100644 index 000000000..2a2dc94cf --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java @@ -0,0 +1,40 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions; +import net.dv8tion.jda.api.interactions.commands.build.Commands; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; + +/** + * The `/form` command. + */ +public class FormCommand extends SlashCommand { + + /** + * The main constructor of this subcommand. + * + * @param createSub form create subcommand + * @param deleteSub form delete subcommand + * @param closeSub form close subcommand + * @param reopenSub form reopen subcommand + * @param detailsSub form details subcommand + * @param modifySub form modify subcommand + * @param addFieldSub form add-field subcommand + * @param removeFieldSub form remove-field subcommand + * @param showSub form show subcommands + * @param attachSub form attach subcommand + * @param detachSub form detach subcommand + * @param submissionsGetSub form submissions-get subcommand + * @param submissionsDeleteSub form submissions-delete subcommand + * + */ + public FormCommand(CreateFormSubcommand createSub, DeleteFormSubcommand deleteSub, CloseFormSubcommand closeSub, + ReopenFormSubcommand reopenSub, DetailsFormSubcommand detailsSub, ModifyFormSubcommand modifySub, + AddFieldFormSubcommand addFieldSub, RemoveFieldFormSubcommand removeFieldSub, ShowFormSubcommand showSub, + AttachFormSubcommand attachSub, DetachFormSubcommand detachSub, + SubmissionsExportFormSubcommand submissionsGetSub, SubmissionsDeleteFormSubcommand submissionsDeleteSub) { + setCommandData(Commands.slash("form", "Commands for managing modal forms") + .setDefaultPermissions(DefaultMemberPermissions.DISABLED).setGuildOnly(true)); + addSubcommands(createSub, deleteSub, closeSub, reopenSub, detailsSub, modifySub, addFieldSub, removeFieldSub, + showSub, attachSub, detachSub, submissionsGetSub, submissionsDeleteSub); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java new file mode 100644 index 000000000..da4dd39a9 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java @@ -0,0 +1,89 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form modify` command. + */ +public class ModifyFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public ModifyFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("modify", "Modify an existing form").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "ID of the form to modify", true, true), + new OptionData(OptionType.STRING, "title", "Form title (shown in modal)"), + new OptionData(OptionType.STRING, "json", "Form inputs data"), + new OptionData(OptionType.CHANNEL, "submit-channel", "Channel to log form submissions in"), + new OptionData(OptionType.STRING, "submit-message", + "Message displayed to the user once they submit the form"), + new OptionData(OptionType.STRING, "expiration", + "UTC time after which the form will not accept further submissions. " + + FormInteractionManager.DATE_FORMAT_STRING), + new OptionData(OptionType.BOOLEAN, "onetime", + "If the form should only accept one submission per user. Defaults to false."))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply(true).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("Couldn't find a form with this ID").queue(); + return; + } + FormData oldForm = formOpt.get(); + + String title = event.getOption("title", oldForm.getTitle(), OptionMapping::getAsString); + String submitChannel = event.getOption("submit-channel", oldForm.getSubmitChannel(), + OptionMapping::getAsString); + String submitMessage = event.getOption("submit-message", oldForm.getSubmitMessage(), + OptionMapping::getAsString); + long expiration; + if (event.getOption("expiration") == null) { + expiration = oldForm.getExpiration(); + } else { + Optional expirationOpt = FormInteractionManager.parseExpiration(event); + if (expirationOpt.isEmpty()) return; + expiration = expirationOpt.get(); + } + + boolean onetime = event.getOption("onetime", oldForm.isOnetime(), OptionMapping::getAsBoolean); + + FormData newForm = new FormData(oldForm.getId(), oldForm.getFields(), title, submitChannel, submitMessage, + oldForm.getMessageId(), oldForm.getMessageChannel(), expiration, oldForm.isClosed(), onetime); + + formsRepo.updateForm(newForm); + + event.getHook().sendMessage("Form updated!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java new file mode 100644 index 000000000..bcbd4dc1b --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java @@ -0,0 +1,93 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form remove-field` command. + */ +public class RemoveFieldFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public RemoveFieldFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("remove-field", "Removse a field from an existing form") + .addOption(OptionType.INTEGER, "form-id", "Form ID to add the field to", true, true) + .addOption(OptionType.INTEGER, "field", "# of the field to remove", true, true)); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply(true).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + int index = event.getOption("field", OptionMapping::getAsInt); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("A form with this ID was not found.").queue(); + return; + } + FormData form = formOpt.get(); + if (index < 0 || index >= form.getFields().size()) { + event.getHook().sendMessage("Field index out of bounds.").queue(); + return; + } + + if (form.getMessageChannel() != null && form.getMessageId() != null && form.getFields().size() <= 1) { + event.getHook().sendMessage( + "Can't remove the last field from an attached form. Detach the form before removing the field") + .queue(); + return; + } + + formsRepo.removeField(form, index); + + event.getHook().sendMessage("Removed field `" + form.getFields().get(index).getLabel() + "` from the form.") + .queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + switch (target.getName()) { + case "form-id" -> event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + case "field" -> { + Long formId = event.getOption("form-id", OptionMapping::getAsLong); + if (formId != null) { + Optional form = formsRepo.getForm(formId); + if (form.isPresent()) { + List choices = new ArrayList<>(); + List fields = form.get().getFields(); + for (int i = 0; i < fields.size(); i++) { + choices.add(new Choice(fields.get(i).getLabel(), i)); + } + event.replyChoices(choices).queue(); + return; + } + } + event.replyChoices().queue(); + } + default -> {} + } + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java new file mode 100644 index 000000000..3b130773b --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java @@ -0,0 +1,71 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form reopen` command. + */ +public class ReopenFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + private final FormInteractionManager interactionManager; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + * @param interactionManager form interaction manager + * @param botConfig main bot configuration + */ + public ReopenFormSubcommand(FormsRepository formsRepo, FormInteractionManager interactionManager, + BotConfig botConfig) { + this.formsRepo = formsRepo; + this.interactionManager = interactionManager; + setCommandData(new SubcommandData("reopen", "Reopen a closed form").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "The ID of a closed form to reopen", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + long id = event.getOption("form-id", OptionMapping::getAsLong); + Optional formOpt = formsRepo.getForm(id); + if (formOpt.isEmpty()) { + event.reply("A form with this ID was not found.").setEphemeral(true).queue(); + return; + } + FormData form = formOpt.get(); + + if (!form.isClosed()) { + event.reply("This form is already opened").setEphemeral(true).queue(); + return; + } + + event.deferReply(true).queue(); + + interactionManager.reopenForm(event.getGuild(), form); + + event.getHook().sendMessage("Form reopened!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms(true).stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java new file mode 100644 index 000000000..c701576d1 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java @@ -0,0 +1,59 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form show` command. + */ +public class ShowFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public ShowFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("show", + "Forcefully opens a form dialog, even if it's closed, or not attached to a message") + .addOption(OptionType.INTEGER, "form-id", "Form ID to add the field to", true, true)); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.reply("A form with this ID was not found.").setEphemeral(true).queue(); + return; + } + FormData form = formOpt.get(); + if (form.getFields().isEmpty()) { + event.reply("You can't open a form with no fields").setEphemeral(true).queue(); + return; + } + event.replyModal(FormInteractionManager.createFormModal(form)).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java new file mode 100644 index 000000000..c0334b264 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java @@ -0,0 +1,77 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.util.Optional; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form submissions-delete` command. + */ +public class SubmissionsDeleteFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public SubmissionsDeleteFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData( + new SubcommandData("submissions-delete", "Deletes submissions of an user in the form").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get submissions for", true, + true), + new OptionData(OptionType.STRING, "user-id", "User to delete submissions of", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + event.deferReply().setEphemeral(true).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("Couldn't find a form with this id").queue(); + return; + } + + String user = event.getOption("user-id", OptionMapping::getAsString); + FormData form = formOpt.get(); + + int count = formsRepo.deleteSubmissions(form, user); + event.getHook().sendMessage("Deleted " + count + " of this user's submissions!").queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + switch (target.getName()) { + case "user-id" -> { + Long formId = event.getOption("form-id", OptionMapping::getAsLong); + if (formId != null) { + Optional form = formsRepo.getForm(formId); + if (form.isPresent()) { + event.replyChoices(formsRepo.getAllSubmissions(form.get()).keySet().stream() + .map(user -> new Choice(user.getUsername(), Long.toString(user.getId()))).toList()) + .queue(); + return; + } + } + event.replyChoices().queue(); + } + case "form-id" -> event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + default -> {} + } + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java new file mode 100644 index 000000000..f28f2c716 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java @@ -0,0 +1,81 @@ +package net.discordjug.javabot.systems.staff_commands.forms.commands; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormUser; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.AutoCompleteQuery; +import net.dv8tion.jda.api.interactions.commands.Command.Choice; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.utils.FileUpload; +import xyz.dynxsty.dih4jda.interactions.AutoCompletable; +import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand.Subcommand; + +/** + * The `/form submissions-export` command. + */ +public class SubmissionsExportFormSubcommand extends Subcommand implements AutoCompletable { + + private final FormsRepository formsRepo; + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + /** + * The main constructor of this subcommand. + * + * @param formsRepo the forms repository + */ + public SubmissionsExportFormSubcommand(FormsRepository formsRepo) { + this.formsRepo = formsRepo; + setCommandData(new SubcommandData("submissions-export", "Export all of the form's submissions").addOptions( + new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get submissions for", true, true))); + } + + @Override + public void execute(SlashCommandInteractionEvent event) { + + event.deferReply().setEphemeral(false).queue(); + Optional formOpt = formsRepo.getForm(event.getOption("form-id", OptionMapping::getAsLong)); + if (formOpt.isEmpty()) { + event.getHook().sendMessage("Couldn't find a form with this id").queue(); + return; + } + + FormData form = formOpt.get(); + Map submissions = formsRepo.getAllSubmissions(form); + JsonObject root = new JsonObject(); + JsonObject details = new JsonObject(); + JsonArray users = new JsonArray(); + for (Entry entry : submissions.entrySet()) { + JsonObject uobj = new JsonObject(); + uobj.addProperty("username", entry.getKey().getUsername()); + uobj.addProperty("submissions", entry.getValue()); + details.add(Long.toString(entry.getKey().getId()), uobj); + users.add(entry.getKey().getUsername()); + } + root.add("users", users); + root.add("details", details); + event.getHook().sendFiles(FileUpload.fromData(gson.toJson(root).getBytes(StandardCharsets.UTF_8), + "submissions_" + form.getId() + ".json")).queue(); + } + + @Override + public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java new file mode 100644 index 000000000..1241c8dc7 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -0,0 +1,327 @@ +package net.discordjug.javabot.systems.staff_commands.forms.dao; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormField; +import net.discordjug.javabot.systems.staff_commands.forms.model.FormUser; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; + +/** + * Dao class that represents the FORMS database. + */ +@RequiredArgsConstructor +@Repository +public class FormsRepository { + private final Gson gson; + private final JdbcTemplate jdbcTemplate; + + /** + * Add a field to a form. + * + * @param form form to add field to + * @param field field to add + * @param index insertion index, or -1 to append + */ + public void addField(FormData form, FormField field, int index) { + Objects.requireNonNull(field); + List fields = new ArrayList<>(form.getFields()); + if (index != -1) { + fields.add(index, field); + } else { + fields.add(field); + } + updateFormData(form, fields); + } + + /** + * Attaches a form to a message. + * + * @param form form to attach + * @param message message to attach the form to + * @param channel channel of the message + */ + public void attachForm(FormData form, MessageChannel channel, Message message) { + Objects.requireNonNull(form); + Objects.requireNonNull(channel); + Objects.requireNonNull(message); + jdbcTemplate.update("update `forms` set `message_id` = ?, `message_channel` = ? where `form_id` = ?", + message.getId(), channel.getId(), form.getId()); + } + + /** + * Set this form's closed state to true. + * + * @param form form to close + */ + public void closeForm(FormData form) { + jdbcTemplate.update("update `forms` set `closed` = true where `form_id` = ?", form.getId()); + } + + /** + * Deletes a form from the database. + * + * @param form form to delete + */ + public void deleteForm(FormData form) { + jdbcTemplate.update("delete from `forms` where `form_id` = ?", form.getId()); + deleteSubmissions(form); + } + + /** + * Deletes all submissions for this form. + * + * @param form form to delete submissions for. + */ + public void deleteSubmissions(FormData form) { + Objects.requireNonNull(form); + jdbcTemplate.update("delete from `form_submissions` where `form_id` = ?", form.getId()); + } + + /** + * Deletes user's submissions from this form. + * + * @param form form to delete submissions for + * @param user user to delete submissions for + * @return number of deleted submissions + */ + public int deleteSubmissions(FormData form, String user) { + Objects.requireNonNull(form); + Objects.requireNonNull(user); + return jdbcTemplate.update("delete from `form_submissions` where `form_id` = ? and `user_id` = ?", form.getId(), + user); + } + + /** + * Detaches a form from a message. + * + * @param form form to detach + */ + public void detachForm(FormData form) { + Objects.requireNonNull(form); + jdbcTemplate.update("update `forms` set `message_id` = NULL, `message_channel` = NULL where `form_id` = ?", + form.getId()); + } + + /** + * Get all forms from the database. + * + * @return A list of forms + */ + public List getAllForms() { + return jdbcTemplate.query("select * from `forms`", (rs, rowNum) -> read(rs)); + } + + /** + * Get all forms matching given closed state. + * + * @param closed the closed state + * @return A list of forms matching the closed state + */ + public List getAllForms(boolean closed) { + return jdbcTemplate.query(con -> { + PreparedStatement statement = con.prepareStatement("select * from `forms` where `closed` = ?"); + statement.setBoolean(1, closed); + return statement; + }, (rs, rowNum) -> read(rs)); + } + + /** + * Get all submissions of this form in an user -> count map. + * + * @param form a form to get submissions for + * @return a map of users and the number of their submissions + */ + public Map getAllSubmissions(FormData form) { + Objects.requireNonNull(form); + List users = jdbcTemplate.query("select * from `form_submissions` where `form_id` = ?", + (rs, rowNum) -> new FormUser(rs.getLong("user_id"), rs.getString("user_name")), form.getId()); + Map map = new HashMap<>(); + for (FormUser user : users) { + map.compute(user, (t, u) -> u == null ? 1 : u + 1); + } + return Collections.unmodifiableMap(map); + } + + /** + * Get a form for given ID. + * + * @param formId form ID to query + * @return optional containing the form, or empty if the form was not found. + */ + public Optional getForm(long formId) { + try { + return Optional.of(jdbcTemplate.queryForObject("select * from `forms` where `form_id` = ?", + (RowMapper) (rs, rowNum) -> read(rs), formId)); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + /** + * Get a count of logged submissions for the given form. + * + * @param form form to get submission for + * @return A total number of logged submission + */ + public int getTotalSubmissionsCount(FormData form) { + Objects.requireNonNull(form); + return jdbcTemplate.queryForObject("select count(*) from `form_submissions` where `form_id` = ?", + (rs, rowNum) -> rs.getInt(1), form.getId()); + } + + /** + * Checks if an user already submitted the form. + * + * @param user user to check + * @param form form to check on + * @return true if the user has submitted at leas one submission, false + * otherwise + */ + public boolean hasSubmitted(User user, FormData form) { + try { + return jdbcTemplate.queryForObject( + "select * from `form_submissions` where `user_id` = ? and `form_id` = ? limit 1", + (rs, rowNum) -> true, user.getId(), form.getId()); + } catch (EmptyResultDataAccessException e) { + return false; + } + } + + /** + * Create a new form entry in the database. + * + * @param data form data to insert. + */ + public void insertForm(@NonNull FormData data) { + Objects.requireNonNull(data); + jdbcTemplate.update(new PreparedStatementCreator() { + + @Override + public PreparedStatement createPreparedStatement(Connection con) throws SQLException { + PreparedStatement statement = con.prepareStatement( + "merge into `forms` (form_id, form_data, title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ? ,?, ?)"); + statement.setLong(1, data.getId()); + statement.setString(2, gson.toJson(data.getFields())); + statement.setString(3, data.getTitle()); + statement.setString(4, data.getSubmitMessage()); + statement.setString(5, data.getSubmitChannel()); + statement.setString(6, data.getMessageId()); + statement.setString(7, data.getMessageChannel()); + statement.setLong(8, data.getExpiration()); + statement.setBoolean(9, data.isOnetime()); + return statement; + } + }); + } + + /** + * Log an user form submission in database. + * + * @param user user to log + * @param form form to log on + */ + public void logSubmission(User user, FormData form) { + Objects.requireNonNull(user); + Objects.requireNonNull(form); + jdbcTemplate.update(con -> { + PreparedStatement statement = con.prepareStatement( + "merge into `form_submissions` (\"timestamp\", `user_id`, `form_id`, `user_name`) values (?, ?, ?, ?)"); + statement.setLong(1, System.currentTimeMillis()); + statement.setString(2, user.getId()); + statement.setLong(3, form.getId()); + statement.setString(4, user.getName()); + return statement; + }); + } + + /** + * Remove a field from a form. Fails silently if the index is out of bounds. + * + * @param form form to remove the field from + * @param index index of the field to remove + */ + public void removeField(FormData form, int index) { + List fields = new ArrayList<>(form.getFields()); + if (index < 0 || index >= fields.size()) return; + fields.remove(index); + updateFormData(form, fields); + } + + /** + * Set this form's closed state to false. + * + * @param form form to re-open + */ + public void reopenForm(FormData form) { + jdbcTemplate.update("update `forms` set `closed` = false where `form_id` = ?", form.getId()); + } + + /** + * Synchronizes form object's values with fields in database. + * + * @param newData new form data. A form with matching ID will be updated in the + * database. + */ + public void updateForm(FormData newData) { + Objects.requireNonNull(newData); + jdbcTemplate.update(con -> { + PreparedStatement statement = con.prepareStatement( + "update `forms` set `title` = ?, `submit_channel` = ?, `submit_message` = ?, `expiration` = ?, `onetime` = ? where `form_id` = ?"); + statement.setString(1, newData.getTitle()); + statement.setString(2, newData.getSubmitChannel()); + statement.setString(3, newData.getSubmitMessage()); + statement.setLong(4, newData.getExpiration()); + statement.setBoolean(5, newData.isOnetime()); + statement.setLong(6, newData.getId()); + return statement; + }); + } + + private FormData read(ResultSet rs) throws SQLException { + List fields = new ArrayList<>(); + for (JsonElement element : JsonParser.parseString(rs.getString("form_data")).getAsJsonArray()) { + fields.add(gson.fromJson(element, FormField.class)); + } + return new FormData(rs.getLong("form_id"), fields, rs.getString("title"), rs.getString("submit_channel"), + rs.getString("submit_message"), rs.getString("message_id"), rs.getString("message_channel"), + rs.getLong("expiration"), rs.getBoolean("closed"), rs.getBoolean("onetime")); + } + + private void updateFormData(FormData form, List fields) { + Objects.requireNonNull(form); + Objects.requireNonNull(fields); + String json = gson.toJson(fields); + jdbcTemplate.update(con -> { + PreparedStatement statement = con + .prepareStatement("update `forms` set `form_data` = ? where `form_id` = ?"); + statement.setString(1, json); + statement.setLong(2, form.getId()); + return statement; + }); + } +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java new file mode 100644 index 000000000..33472d773 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -0,0 +1,161 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Objects; + +import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.LayoutComponent; + +/** + * Class containing information about a form. + */ +public class FormData { + + /** + * Setting {@link FormData#expiration} to this value indicates, that the form + * will never expire. + */ + public static final long EXPIRATION_PERMANENT = -1; + + private final boolean closed; + private final long expiration; + private final List fields; + private final long id; + private final String messageId; + private final String messageChannel; + private final boolean onetime; + private final String submitChannel; + private final String submitMessage; + private final String title; + + /** + * Main constructor. + * + * @param id The id of this form. The id should be equal to + * timestamp of creation of this form. + * @param fields List of text inputs of this form. + * @param title Form title shown in the submission modal and in various + * commands. + * @param submitChannel Target channel where the form submissions will be sent. + * @param submitMessage A message presented to the user after they successfully + * submit the form. + * @param messageId ID of the message this form is attached to. A null + * value indicates that this form is not attached to any + * message. + * @param messageChannel Channel of the message this form is attached to. A null + * value indicates that this form is not attached. + * @param expiration Time after which this form will not accept further + * submissions. Value of + * {@link FormData#EXPIRATION_PERMANENT} indicates that + * this form will never expire. + * @param closed Closed state of this form. A closed form doesn't accept + * further submissions and has its components disabled. + * @param onetime Whether or not this form accepts one submission per + * user. + */ + public FormData(long id, List fields, String title, String submitChannel, String submitMessage, + String messageId, String messageChannel, long expiration, boolean closed, boolean onetime) { + this.id = id; + this.fields = Objects.requireNonNull(fields); + this.title = Objects.requireNonNull(title); + this.submitChannel = Objects.requireNonNull(submitChannel); + this.submitMessage = submitMessage; + this.messageId = messageId; + this.messageChannel = messageChannel; + this.expiration = expiration; + this.closed = closed; + this.onetime = onetime; + } + + /** + * Creates text components for use in the submission modal. + * + * @return Lsit of layout components for use in the submission modal. + */ + public LayoutComponent[] createComponents() { + LayoutComponent[] array = new LayoutComponent[fields.size()]; + for (int i = 0; i < array.length; i++) { + array[i] = ActionRow.of(fields.get(i).createTextInput("text" + i)); + } + return array; + } + + public long getExpiration() { + return expiration; + } + + public List getFields() { + return fields == null ? Collections.emptyList() : fields; + } + + public long getId() { + return id; + } + + public String getMessageChannel() { + return messageChannel; + } + + public String getMessageId() { + return messageId; + } + + public String getSubmitChannel() { + return submitChannel; + } + + public String getSubmitMessage() { + return submitMessage; + } + + public String getTitle() { + return title; + } + + /** + * Checks if the form can expire. + * + * @return true if this form has an expiration time. + */ + public boolean hasExpirationTime() { + return expiration > 0; + } + + /** + * Checks if the current form still accepts submissions. + * + * @return true, if the form has expired, false, if the form is still valid or + * can't expire. + */ + public boolean hasExpired() { + return hasExpirationTime() && expiration < System.currentTimeMillis(); + } + + public boolean isClosed() { + return closed; + } + + public boolean isOnetime() { + return onetime; + } + + @Override + public String toString() { + String prefix; + if (closed) { + prefix = "Closed"; + } else if (expiration == EXPIRATION_PERMANENT) { + prefix = "Permanent"; + } else if (expiration < System.currentTimeMillis()) { + prefix = "Expired"; + } else { + prefix = FormInteractionManager.DATE_FORMAT.format(new Date(expiration)) + " UTC"; + } + + return String.format("[%s] %s", prefix, title); + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java new file mode 100644 index 000000000..d3fb45929 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -0,0 +1,95 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +import net.dv8tion.jda.api.interactions.components.text.TextInput; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; + +/** + * Represents a form field. + */ +public class FormField { + private final String label; + private final int max; + private final int min; + private final String placeholder; + private final boolean required; + private final String style; + private final String value; + + /** + * Main constructor. + * + * @param label text field lable. + * @param max maximum characters allowed. + * @param min minimum characters allowed. + * @param placeholder text field placeholder. + * @param required whether or not this field is required. + * @param style text field style. One of {@link TextInputStyle} values. + * Case insensitive. + * @param value initial value of this text field. + */ + public FormField(String label, int max, int min, String placeholder, boolean required, String style, String value) { + this.label = label; + this.max = max; + this.min = min; + this.placeholder = placeholder; + this.required = required; + this.style = style; + this.value = value; + } + + /** + * Create a standalone text input from this field. + * + * @param id ID of this text input. + * @return text input ready to use in a modal. + */ + public TextInput createTextInput(String id) { + return TextInput.create(id, getLabel(), getStyle()).setRequiredRange(getMin(), getMax()) + .setPlaceholder(getPlaceholder()).setRequired(isRequired()).setValue(getValue()).build(); + } + + public String getLabel() { + return label; + } + + public int getMax() { + return max <= 0 ? 64 : max; + } + + public int getMin() { + return Math.max(0, min); + } + + public String getPlaceholder() { + return placeholder; + } + + /** + * Get a parsed style of this field's text input. + * + * @return one of {@link TextInputStyle} values. Defaults to + * {@link TextInputStyle#SHORT} if the stored value is invalid. + */ + public TextInputStyle getStyle() { + try { + return style == null ? TextInputStyle.SHORT : TextInputStyle.valueOf(style.toUpperCase()); + } catch (IllegalArgumentException e) { + return TextInputStyle.SHORT; + } + } + + public String getValue() { + return value; + } + + public boolean isRequired() { + return required; + } + + @Override + public String toString() { + return "FormField [label=" + label + ", max=" + max + ", min=" + min + ", placeholder=" + placeholder + + ", required=" + required + ", style=" + style + ", value=" + value + "]"; + } + +} diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java new file mode 100644 index 000000000..2d515d687 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java @@ -0,0 +1,44 @@ +package net.discordjug.javabot.systems.staff_commands.forms.model; + +import java.util.Objects; + +/** + * Represents an user who submitted a form. + */ +public class FormUser { + private final long id; + private final String username; + + /** + * The main constructor. + * + * @param id user's id + * @param username user's username + */ + public FormUser(long id, String username) { + this.id = id; + this.username = username; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + FormUser other = (FormUser) obj; + return id == other.id && Objects.equals(username, other.username); + } + + public long getId() { + return id; + } + + public String getUsername() { + return username; + } + + @Override + public int hashCode() { + return Objects.hash(id, username); + } + +} diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql new file mode 100644 index 000000000..f026b0e77 --- /dev/null +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -0,0 +1,23 @@ +CREATE TABLE forms ( + form_id BIGINT NOT NULL, + form_data VARCHAR NOT NULL, + title VARCHAR NOT NULL, + submit_message VARCHAR, + submit_channel VARCHAR NOT NULL, + message_id VARCHAR, + message_channel VARCHAR, + expiration BIGINT NOT NULL, + closed BOOLEAN NOT NULL DEFAULT FALSE, + onetime BOOLEAN NOT NULL, + PRIMARY KEY (form_id) +); + +CREATE TABLE form_submissions ( + "timestamp" BIGINT NOT NULL, + user_id VARCHAR NOT NULL, + form_id BIGINT NOT NULL, + user_name VARCHAR NOT NULL, + PRIMARY KEY ("timestamp") +); + +CREATE INDEX FORM_SUBMISSIONS_USER_ID_IDX ON form_submissions (user_id,form_id); From bdf0b1af2d73fd6c0ef7966de2f939fbd26cd455 Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 15:58:17 +0200 Subject: [PATCH 02/14] Store form fields in a separate table --- .../commands/AddFieldFormSubcommand.java | 4 +- .../forms/dao/FormsRepository.java | 78 +++++++------------ .../staff_commands/forms/model/FormField.java | 30 ++++--- .../database/migrations/09-08-2025_forms.sql | 21 ++++- 4 files changed, 68 insertions(+), 65 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java index e29885c56..8760d5f2d 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java @@ -66,7 +66,7 @@ public void execute(SlashCommandInteractionEvent event) { return; } - formsRepo.addField(form, createFormFieldFromEvent(event), index); + formsRepo.addField(form, createFormFieldFromEvent(event)); event.getHook().sendMessage("Added a new field to the form.").queue(); } @@ -98,6 +98,6 @@ private static FormField createFormFieldFromEvent(SlashCommandInteractionEvent e }); String value = e.getOption("value", OptionMapping::getAsString); - return new FormField(label, max, min, placeholder, required, style.name(), value); + return new FormField(label, max, min, placeholder, required, style, value, 0); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index 1241c8dc7..8094fad5c 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -4,7 +4,6 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -18,10 +17,6 @@ import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; - import lombok.NonNull; import lombok.RequiredArgsConstructor; import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; @@ -30,6 +25,7 @@ import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; /** * Dao class that represents the FORMS database. @@ -37,7 +33,6 @@ @RequiredArgsConstructor @Repository public class FormsRepository { - private final Gson gson; private final JdbcTemplate jdbcTemplate; /** @@ -45,17 +40,13 @@ public class FormsRepository { * * @param form form to add field to * @param field field to add - * @param index insertion index, or -1 to append */ - public void addField(FormData form, FormField field, int index) { - Objects.requireNonNull(field); - List fields = new ArrayList<>(form.getFields()); - if (index != -1) { - fields.add(index, field); - } else { - fields.add(field); - } - updateFormData(form, fields); + public void addField(FormData form, FormField field) { + jdbcTemplate.update( + "INSERT INTO FORM_FIELDS (FORM_ID, LABEL, MIN, MAX, PLACEHOLDER, REQUIRED, \"style\", INITIAL) " + + "VALUES(?, ?, ?, ?, ?, ?, ?, ?)", + form.getId(), field.getLabel(), field.getMin(), field.getMax(), field.getPlaceholder(), + field.isRequired(), field.getStyle().name(), field.getValue()); } /** @@ -133,7 +124,7 @@ public void detachForm(FormData form) { * @return A list of forms */ public List getAllForms() { - return jdbcTemplate.query("select * from `forms`", (rs, rowNum) -> read(rs)); + return jdbcTemplate.query("select * from `forms`", (rs, rowNum) -> read(rs, readFormFields(rowNum))); } /** @@ -147,7 +138,7 @@ public List getAllForms(boolean closed) { PreparedStatement statement = con.prepareStatement("select * from `forms` where `closed` = ?"); statement.setBoolean(1, closed); return statement; - }, (rs, rowNum) -> read(rs)); + }, (rs, rowNum) -> read(rs, readFormFields(rowNum))); } /** @@ -176,7 +167,7 @@ public Map getAllSubmissions(FormData form) { public Optional getForm(long formId) { try { return Optional.of(jdbcTemplate.queryForObject("select * from `forms` where `form_id` = ?", - (RowMapper) (rs, rowNum) -> read(rs), formId)); + (RowMapper) (rs, rowNum) -> read(rs, readFormFields(formId)), formId)); } catch (EmptyResultDataAccessException e) { return Optional.empty(); } @@ -224,16 +215,14 @@ public void insertForm(@NonNull FormData data) { @Override public PreparedStatement createPreparedStatement(Connection con) throws SQLException { PreparedStatement statement = con.prepareStatement( - "merge into `forms` (form_id, form_data, title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ? ,?, ?)"); - statement.setLong(1, data.getId()); - statement.setString(2, gson.toJson(data.getFields())); - statement.setString(3, data.getTitle()); - statement.setString(4, data.getSubmitMessage()); - statement.setString(5, data.getSubmitChannel()); - statement.setString(6, data.getMessageId()); - statement.setString(7, data.getMessageChannel()); - statement.setLong(8, data.getExpiration()); - statement.setBoolean(9, data.isOnetime()); + "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); + statement.setString(1, data.getTitle()); + statement.setString(2, data.getSubmitMessage()); + statement.setString(3, data.getSubmitChannel()); + statement.setString(4, data.getMessageId()); + statement.setString(5, data.getMessageChannel()); + statement.setLong(6, data.getExpiration()); + statement.setBoolean(7, data.isOnetime()); return statement; } }); @@ -266,10 +255,9 @@ public void logSubmission(User user, FormData form) { * @param index index of the field to remove */ public void removeField(FormData form, int index) { - List fields = new ArrayList<>(form.getFields()); + List fields = form.getFields(); if (index < 0 || index >= fields.size()) return; - fields.remove(index); - updateFormData(form, fields); + jdbcTemplate.update("delete from `form_fields` where `id` = ?", fields.get(index).getId()); } /** @@ -302,26 +290,20 @@ public void updateForm(FormData newData) { }); } - private FormData read(ResultSet rs) throws SQLException { - List fields = new ArrayList<>(); - for (JsonElement element : JsonParser.parseString(rs.getString("form_data")).getAsJsonArray()) { - fields.add(gson.fromJson(element, FormField.class)); - } + private List readFormFields(long formId) { + return jdbcTemplate.query("select * from `form_fields` where `form_id` = ?", (rs, rowNum) -> readField(rs), + formId); + } + + private static FormData read(ResultSet rs, List fields) throws SQLException { return new FormData(rs.getLong("form_id"), fields, rs.getString("title"), rs.getString("submit_channel"), rs.getString("submit_message"), rs.getString("message_id"), rs.getString("message_channel"), rs.getLong("expiration"), rs.getBoolean("closed"), rs.getBoolean("onetime")); } - private void updateFormData(FormData form, List fields) { - Objects.requireNonNull(form); - Objects.requireNonNull(fields); - String json = gson.toJson(fields); - jdbcTemplate.update(con -> { - PreparedStatement statement = con - .prepareStatement("update `forms` set `form_data` = ? where `form_id` = ?"); - statement.setString(1, json); - statement.setLong(2, form.getId()); - return statement; - }); + private static FormField readField(ResultSet rs) throws SQLException { + return new FormField(rs.getString("label"), rs.getInt("max"), rs.getInt("min"), rs.getString("placeholder"), + rs.getBoolean("required"), TextInputStyle.valueOf(rs.getString("style").toUpperCase()), + rs.getString("initial"), rs.getInt("id")); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java index d3fb45929..666a22c1b 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -7,12 +7,13 @@ * Represents a form field. */ public class FormField { + private final long id; private final String label; private final int max; private final int min; private final String placeholder; private final boolean required; - private final String style; + private final TextInputStyle style; private final String value; /** @@ -26,8 +27,11 @@ public class FormField { * @param style text field style. One of {@link TextInputStyle} values. * Case insensitive. * @param value initial value of this text field. + * @param id unique ID of this field. */ - public FormField(String label, int max, int min, String placeholder, boolean required, String style, String value) { + public FormField(String label, int max, int min, String placeholder, boolean required, TextInputStyle style, + String value, long id) { + this.id = id; this.label = label; this.max = max; this.min = min; @@ -48,6 +52,15 @@ public TextInput createTextInput(String id) { .setPlaceholder(getPlaceholder()).setRequired(isRequired()).setValue(getValue()).build(); } + /** + * Get this field's unique ID. + * + * @return unique ID of the field. + */ + public long getId() { + return id; + } + public String getLabel() { return label; } @@ -65,17 +78,12 @@ public String getPlaceholder() { } /** - * Get a parsed style of this field's text input. - * - * @return one of {@link TextInputStyle} values. Defaults to - * {@link TextInputStyle#SHORT} if the stored value is invalid. + * Get the style of this field's text input. + * + * @return one of {@link TextInputStyle} values. */ public TextInputStyle getStyle() { - try { - return style == null ? TextInputStyle.SHORT : TextInputStyle.valueOf(style.toUpperCase()); - } catch (IllegalArgumentException e) { - return TextInputStyle.SHORT; - } + return style; } public String getValue() { diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql index f026b0e77..3e09fe990 100644 --- a/src/main/resources/database/migrations/09-08-2025_forms.sql +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -1,17 +1,30 @@ CREATE TABLE forms ( - form_id BIGINT NOT NULL, - form_data VARCHAR NOT NULL, + form_id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR NOT NULL, submit_message VARCHAR, submit_channel VARCHAR NOT NULL, message_id VARCHAR, message_channel VARCHAR, - expiration BIGINT NOT NULL, + expiration BIGINT NOT NULL DEFAULT -1, closed BOOLEAN NOT NULL DEFAULT FALSE, - onetime BOOLEAN NOT NULL, + onetime BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (form_id) ); +CREATE TABLE FORM_FIELDS ( + ID BIGINT NOT NULL AUTO_INCREMENT, + FORM_ID BIGINT NOT NULL, + LABEL CHARACTER VARYING NOT NULL, + MIN INTEGER DEFAULT 0 NOT NULL, + MAX INTEGER DEFAULT 16 NOT NULL, + PLACEHOLDER CHARACTER VARYING, + REQUIRED BOOLEAN DEFAULT FALSE NOT NULL, + "style" ENUM('SHORT', 'PARAGRAPH') DEFAULT 'SHORT' NOT NULL, + INITIAL CHARACTER VARYING, + PRIMARY KEY (ID), + FOREIGN KEY (FORM_ID) REFERENCES FORMS(FORM_ID) ON DELETE CASCADE ON UPDATE RESTRICT +); + CREATE TABLE form_submissions ( "timestamp" BIGINT NOT NULL, user_id VARCHAR NOT NULL, From b62574d58a428a3e4b9c5d2874e1d4a2630b5cb4 Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:00:52 +0200 Subject: [PATCH 03/14] Use default values for all `NOT NULL` fields --- src/main/resources/database/migrations/09-08-2025_forms.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql index 3e09fe990..05a90c2b4 100644 --- a/src/main/resources/database/migrations/09-08-2025_forms.sql +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -1,10 +1,10 @@ CREATE TABLE forms ( form_id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR NOT NULL, - submit_message VARCHAR, + submit_message VARCHAR DEFAULT NULL, submit_channel VARCHAR NOT NULL, - message_id VARCHAR, - message_channel VARCHAR, + message_id VARCHAR DEFAULT NULL, + message_channel VARCHAR DEFAULT NULL, expiration BIGINT NOT NULL DEFAULT -1, closed BOOLEAN NOT NULL DEFAULT FALSE, onetime BOOLEAN NOT NULL DEFAULT FALSE, From 2c971722cbade249ba256b17ca4dfdbd14dfb6e6 Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:21:21 +0200 Subject: [PATCH 04/14] Store form's message channel and id as longs instead of Strings --- .../forms/FormInteractionManager.java | 12 ++++----- .../forms/commands/AttachFormSubcommand.java | 2 +- .../forms/commands/CreateFormSubcommand.java | 2 +- .../forms/commands/DeleteFormSubcommand.java | 3 ++- .../forms/commands/DetachFormSubcommand.java | 7 ++--- .../forms/commands/DetailsFormSubcommand.java | 7 +++-- .../forms/commands/ModifyFormSubcommand.java | 6 ++--- .../commands/RemoveFieldFormSubcommand.java | 2 +- .../forms/dao/FormsRepository.java | 18 ++++++++----- .../staff_commands/forms/model/FormData.java | 27 +++++++++++-------- .../database/migrations/09-08-2025_forms.sql | 6 ++--- 11 files changed, 51 insertions(+), 41 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java index 32395d3da..dccbb5264 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -76,9 +76,9 @@ public class FormInteractionManager implements ButtonHandler, ModalHandler { public void closeForm(Guild guild, FormData form) { formsRepo.closeForm(form); - if (form.getMessageChannel() != null && form.getMessageId() != null) { - TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); - formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + if (form.isAttached()) { + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel().get()); + formChannel.retrieveMessageById(form.getMessageId().get()).queue(msg -> { mapFormMessageButtons(msg, btn -> { String cptId = btn.getId(); String[] split = ComponentIdBuilder.split(cptId); @@ -190,9 +190,9 @@ public void mapFormMessageButtons(Message msg, Function mapper) public void reopenForm(Guild guild, FormData form) { formsRepo.reopenForm(form); - if (form.getMessageChannel() != null && form.getMessageId() != null) { - TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); - formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + if (form.isAttached()) { + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel().get()); + formChannel.retrieveMessageById(form.getMessageId().get()).queue(msg -> { mapFormMessageButtons(msg, btn -> { String cptId = btn.getId(); String[] split = ComponentIdBuilder.split(cptId); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java index 0c31e0ed5..abc9ef7b6 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java @@ -63,7 +63,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - if (form.getMessageChannel() != null && form.getMessageId() != null) { + if (form.isAttached()) { event.getHook() .sendMessage("The form seems to already be attached to a message. Detach it before continuing.") .queue(); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java index aab91e071..7060084d1 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java @@ -52,7 +52,7 @@ public void execute(SlashCommandInteractionEvent event) { long formId = System.currentTimeMillis(); FormData form = new FormData(formId, List.of(), event.getOption("title", OptionMapping::getAsString), - event.getOption("submit-channel", OptionMapping::getAsChannel).getId(), + event.getOption("submit-channel", OptionMapping::getAsChannel).getIdLong(), event.getOption("submit-message", null, OptionMapping::getAsString), null, null, expiration, false, event.getOption("onetime", false, OptionMapping::getAsBoolean)); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java index a368ab5b4..feecf00f9 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java @@ -48,8 +48,9 @@ public void execute(SlashCommandInteractionEvent event) { FormData form = formOpt.get(); formsRepo.deleteForm(form); - if (form.getMessageChannel() != null && form.getMessageId() != null) { + if (form.isAttached()) { DetachFormSubcommand.detachFromMessage(form, event.getGuild()); + // TODO send a warning } event.getHook().sendMessage("Form deleted!").queue(); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java index b40446972..b3b8cac0d 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java @@ -54,7 +54,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - if (form.getMessageChannel() == null && form.getMessageId() == null) { + if (!form.isAttached()) { event.getHook().sendMessage("This form doesn't seem to be attached to a message").queue(); return; } @@ -80,8 +80,9 @@ public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCo * @param guild guild this form is contained in */ public static void detachFromMessage(FormData form, Guild guild) { - TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel()); - formChannel.retrieveMessageById(form.getMessageId()).queue(msg -> { + if(!form.isAttached()) return; + TextChannel formChannel = guild.getTextChannelById(form.getMessageChannel().get()); + formChannel.retrieveMessageById(form.getMessageId().get()).queue(msg -> { List components = msg.getActionRows().stream().map(row -> { ItemComponent[] cpts = row.getComponents().stream().filter(cpt -> { if (cpt instanceof Button btn) { diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java index 008381f56..5cc7d283b 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java @@ -80,11 +80,10 @@ private EmbedBuilder createFormDetailsEmbed(FormData form, Guild guild) { addCodeblockField(builder, "State", form.isClosed() ? "Closed" : form.hasExpired() ? "Expired" : "Open", false); builder.addField("Attached in", - form.getMessageChannel() == null ? "*Not attached*" : "<#" + form.getMessageChannel() + ">", true); + form.isAttached() ? "<#" + form.getMessageChannel().get() + ">" : "*Not attached*", true); builder.addField("Attached to", - form.getMessageChannel() == null || form.getMessageId() == null ? "*Not attached*" - : String.format("[Link](https://discord.com/channels/%s/%s/%s)", guild.getId(), - form.getMessageChannel(), form.getMessageId()), + form.isAttached() ? String.format("[Link](https://discord.com/channels/%s/%s/%s)", guild.getId(), + form.getMessageChannel().get(), form.getMessageId().get()) : "*Not attached*", true); builder.addField("Submissions channel", "<#" + form.getSubmitChannel() + ">", true); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java index da4dd39a9..90c80a013 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java @@ -56,8 +56,7 @@ public void execute(SlashCommandInteractionEvent event) { FormData oldForm = formOpt.get(); String title = event.getOption("title", oldForm.getTitle(), OptionMapping::getAsString); - String submitChannel = event.getOption("submit-channel", oldForm.getSubmitChannel(), - OptionMapping::getAsString); + long submitChannel = event.getOption("submit-channel", oldForm.getSubmitChannel(), OptionMapping::getAsLong); String submitMessage = event.getOption("submit-message", oldForm.getSubmitMessage(), OptionMapping::getAsString); long expiration; @@ -72,7 +71,8 @@ public void execute(SlashCommandInteractionEvent event) { boolean onetime = event.getOption("onetime", oldForm.isOnetime(), OptionMapping::getAsBoolean); FormData newForm = new FormData(oldForm.getId(), oldForm.getFields(), title, submitChannel, submitMessage, - oldForm.getMessageId(), oldForm.getMessageChannel(), expiration, oldForm.isClosed(), onetime); + oldForm.getMessageId().orElse(null), oldForm.getMessageChannel().orElse(null), expiration, + oldForm.isClosed(), onetime); formsRepo.updateForm(newForm); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java index bcbd4dc1b..6c190a748 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java @@ -52,7 +52,7 @@ public void execute(SlashCommandInteractionEvent event) { return; } - if (form.getMessageChannel() != null && form.getMessageId() != null && form.getFields().size() <= 1) { + if (form.isAttached() && form.getFields().size() <= 1) { event.getHook().sendMessage( "Can't remove the last field from an attached form. Detach the form before removing the field") .queue(); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index 8094fad5c..684dc148a 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -218,9 +218,9 @@ public PreparedStatement createPreparedStatement(Connection con) throws SQLExcep "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); statement.setString(1, data.getTitle()); statement.setString(2, data.getSubmitMessage()); - statement.setString(3, data.getSubmitChannel()); - statement.setString(4, data.getMessageId()); - statement.setString(5, data.getMessageChannel()); + statement.setLong(3, data.getSubmitChannel()); + statement.setObject(4, data.getMessageId().orElse(null)); + statement.setObject(5, data.getMessageChannel().orElse(null)); statement.setLong(6, data.getExpiration()); statement.setBoolean(7, data.isOnetime()); return statement; @@ -281,7 +281,7 @@ public void updateForm(FormData newData) { PreparedStatement statement = con.prepareStatement( "update `forms` set `title` = ?, `submit_channel` = ?, `submit_message` = ?, `expiration` = ?, `onetime` = ? where `form_id` = ?"); statement.setString(1, newData.getTitle()); - statement.setString(2, newData.getSubmitChannel()); + statement.setLong(2, newData.getSubmitChannel()); statement.setString(3, newData.getSubmitMessage()); statement.setLong(4, newData.getExpiration()); statement.setBoolean(5, newData.isOnetime()); @@ -296,9 +296,13 @@ private List readFormFields(long formId) { } private static FormData read(ResultSet rs, List fields) throws SQLException { - return new FormData(rs.getLong("form_id"), fields, rs.getString("title"), rs.getString("submit_channel"), - rs.getString("submit_message"), rs.getString("message_id"), rs.getString("message_channel"), - rs.getLong("expiration"), rs.getBoolean("closed"), rs.getBoolean("onetime")); + Long messageId = rs.getLong("message_id"); + if (rs.wasNull()) messageId = null; + Long messageChannel = rs.getLong("message_channel"); + if (rs.wasNull()) messageChannel = null; + return new FormData(rs.getLong("form_id"), fields, rs.getString("title"), rs.getLong("submit_channel"), + rs.getString("submit_message"), messageId, messageChannel, rs.getLong("expiration"), + rs.getBoolean("closed"), rs.getBoolean("onetime")); } private static FormField readField(ResultSet rs) throws SQLException { diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java index 33472d773..933ab0a9b 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -4,6 +4,7 @@ import java.util.Date; import java.util.List; import java.util.Objects; +import java.util.Optional; import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; import net.dv8tion.jda.api.interactions.components.ActionRow; @@ -24,10 +25,10 @@ public class FormData { private final long expiration; private final List fields; private final long id; - private final String messageId; - private final String messageChannel; + private final Long messageId; + private final Long messageChannel; private final boolean onetime; - private final String submitChannel; + private final long submitChannel; private final String submitMessage; private final String title; @@ -56,12 +57,12 @@ public class FormData { * @param onetime Whether or not this form accepts one submission per * user. */ - public FormData(long id, List fields, String title, String submitChannel, String submitMessage, - String messageId, String messageChannel, long expiration, boolean closed, boolean onetime) { + public FormData(long id, List fields, String title, long submitChannel, String submitMessage, + Long messageId, Long messageChannel, long expiration, boolean closed, boolean onetime) { this.id = id; this.fields = Objects.requireNonNull(fields); this.title = Objects.requireNonNull(title); - this.submitChannel = Objects.requireNonNull(submitChannel); + this.submitChannel = submitChannel; this.submitMessage = submitMessage; this.messageId = messageId; this.messageChannel = messageChannel; @@ -70,6 +71,10 @@ public FormData(long id, List fields, String title, String submitChan this.onetime = onetime; } + public boolean isAttached() { + return messageChannel != null && messageId != null; + } + /** * Creates text components for use in the submission modal. * @@ -95,15 +100,15 @@ public long getId() { return id; } - public String getMessageChannel() { - return messageChannel; + public Optional getMessageChannel() { + return Optional.ofNullable(messageChannel); } - public String getMessageId() { - return messageId; + public Optional getMessageId() { + return Optional.ofNullable(messageId); } - public String getSubmitChannel() { + public long getSubmitChannel() { return submitChannel; } diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql index 05a90c2b4..eb4359a5c 100644 --- a/src/main/resources/database/migrations/09-08-2025_forms.sql +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -2,9 +2,9 @@ CREATE TABLE forms ( form_id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR NOT NULL, submit_message VARCHAR DEFAULT NULL, - submit_channel VARCHAR NOT NULL, - message_id VARCHAR DEFAULT NULL, - message_channel VARCHAR DEFAULT NULL, + submit_channel BIGINT NOT NULL, + message_id BIGINT DEFAULT NULL, + message_channel BIGINT DEFAULT NULL, expiration BIGINT NOT NULL DEFAULT -1, closed BOOLEAN NOT NULL DEFAULT FALSE, onetime BOOLEAN NOT NULL DEFAULT FALSE, From 7db911a59f508696982cfde757dc1be06ad2d54c Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:45:37 +0200 Subject: [PATCH 05/14] Changed submissions table structure to use unique ids --- .../forms/FormInteractionManager.java | 5 +-- .../SubmissionsDeleteFormSubcommand.java | 33 +++++-------------- .../forms/dao/FormsRepository.java | 30 ++++++----------- .../database/migrations/09-08-2025_forms.sql | 14 ++++---- 4 files changed, 29 insertions(+), 53 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java index dccbb5264..cd59b794e 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -149,8 +149,9 @@ public void handleModal(ModalInteractionEvent event, List values) return; } - channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(); - formsRepo.logSubmission(event.getUser(), form); + channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(msg -> { + formsRepo.logSubmission(event.getUser(), form, msg); + }); event.getHook() .sendMessage( diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java index c0334b264..c9082cb6f 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java @@ -4,6 +4,7 @@ import net.discordjug.javabot.systems.staff_commands.forms.dao.FormsRepository; import net.discordjug.javabot.systems.staff_commands.forms.model.FormData; +import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.interactions.AutoCompleteQuery; @@ -29,11 +30,9 @@ public class SubmissionsDeleteFormSubcommand extends Subcommand implements AutoC */ public SubmissionsDeleteFormSubcommand(FormsRepository formsRepo) { this.formsRepo = formsRepo; - setCommandData( - new SubcommandData("submissions-delete", "Deletes submissions of an user in the form").addOptions( - new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get submissions for", true, - true), - new OptionData(OptionType.STRING, "user-id", "User to delete submissions of", true, true))); + setCommandData(new SubcommandData("submissions-delete", "Deletes submissions of an user in the form") + .addOptions(new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get submissions for", + true, true), new OptionData(OptionType.USER, "user", "User to delete submissions of", true))); } @Override @@ -45,7 +44,7 @@ public void execute(SlashCommandInteractionEvent event) { return; } - String user = event.getOption("user-id", OptionMapping::getAsString); + User user = event.getOption("user", OptionMapping::getAsUser); FormData form = formOpt.get(); int count = formsRepo.deleteSubmissions(form, user); @@ -54,24 +53,8 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { - switch (target.getName()) { - case "user-id" -> { - Long formId = event.getOption("form-id", OptionMapping::getAsLong); - if (formId != null) { - Optional form = formsRepo.getForm(formId); - if (form.isPresent()) { - event.replyChoices(formsRepo.getAllSubmissions(form.get()).keySet().stream() - .map(user -> new Choice(user.getUsername(), Long.toString(user.getId()))).toList()) - .queue(); - return; - } - } - event.replyChoices().queue(); - } - case "form-id" -> event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) - .queue(); - default -> {} - } + event.replyChoices( + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index 684dc148a..914cbcf7c 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -80,17 +80,6 @@ public void closeForm(FormData form) { */ public void deleteForm(FormData form) { jdbcTemplate.update("delete from `forms` where `form_id` = ?", form.getId()); - deleteSubmissions(form); - } - - /** - * Deletes all submissions for this form. - * - * @param form form to delete submissions for. - */ - public void deleteSubmissions(FormData form) { - Objects.requireNonNull(form); - jdbcTemplate.update("delete from `form_submissions` where `form_id` = ?", form.getId()); } /** @@ -100,11 +89,11 @@ public void deleteSubmissions(FormData form) { * @param user user to delete submissions for * @return number of deleted submissions */ - public int deleteSubmissions(FormData form, String user) { + public int deleteSubmissions(FormData form, User user) { Objects.requireNonNull(form); Objects.requireNonNull(user); return jdbcTemplate.update("delete from `form_submissions` where `form_id` = ? and `user_id` = ?", form.getId(), - user); + user.getIdLong()); } /** @@ -197,7 +186,7 @@ public boolean hasSubmitted(User user, FormData form) { try { return jdbcTemplate.queryForObject( "select * from `form_submissions` where `user_id` = ? and `form_id` = ? limit 1", - (rs, rowNum) -> true, user.getId(), form.getId()); + (rs, rowNum) -> true, user.getIdLong(), form.getId()); } catch (EmptyResultDataAccessException e) { return false; } @@ -231,17 +220,18 @@ public PreparedStatement createPreparedStatement(Connection con) throws SQLExcep /** * Log an user form submission in database. * - * @param user user to log - * @param form form to log on + * @param user user to log + * @param form form to log on + * @param message message containing details about this user's submission */ - public void logSubmission(User user, FormData form) { + public void logSubmission(User user, FormData form, Message message) { Objects.requireNonNull(user); Objects.requireNonNull(form); jdbcTemplate.update(con -> { PreparedStatement statement = con.prepareStatement( - "merge into `form_submissions` (\"timestamp\", `user_id`, `form_id`, `user_name`) values (?, ?, ?, ?)"); - statement.setLong(1, System.currentTimeMillis()); - statement.setString(2, user.getId()); + "insert into `form_submissions` (`message_id`, `user_id`, `form_id`, `user_name`) values (?, ?, ?, ?)"); + statement.setLong(1, message.getIdLong()); + statement.setLong(2, user.getIdLong()); statement.setLong(3, form.getId()); statement.setString(4, user.getName()); return statement; diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql index eb4359a5c..e3d1d55bb 100644 --- a/src/main/resources/database/migrations/09-08-2025_forms.sql +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -25,12 +25,14 @@ CREATE TABLE FORM_FIELDS ( FOREIGN KEY (FORM_ID) REFERENCES FORMS(FORM_ID) ON DELETE CASCADE ON UPDATE RESTRICT ); -CREATE TABLE form_submissions ( - "timestamp" BIGINT NOT NULL, - user_id VARCHAR NOT NULL, - form_id BIGINT NOT NULL, - user_name VARCHAR NOT NULL, - PRIMARY KEY ("timestamp") +CREATE TABLE FORM_SUBMISSIONS ( + ID BIGINT NOT NULL AUTO_INCREMENT, + MESSAGE_ID BIGINT NOT NULL, + USER_ID BIGINT NOT NULL, + FORM_ID BIGINT NOT NULL, + USER_NAME VARCHAR NOT NULL, + PRIMARY KEY (ID), + FOREIGN KEY (FORM_ID) REFERENCES FORMS(FORM_ID) ON DELETE CASCADE ON UPDATE RESTRICT ); CREATE INDEX FORM_SUBMISSIONS_USER_ID_IDX ON form_submissions (user_id,form_id); From e2c6372c6ee05357fcdc3b5fde85674735513c33 Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:46:20 +0200 Subject: [PATCH 06/14] Correct `FormsRepository` javadoc --- .../systems/staff_commands/forms/dao/FormsRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index 914cbcf7c..a7718bf40 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -28,7 +28,7 @@ import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; /** - * Dao class that represents the FORMS database. + * Dao class that represents the FORMS table. */ @RequiredArgsConstructor @Repository From 7cb72ed783095eae8b0493a0931a8990809e251d Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:51:07 +0200 Subject: [PATCH 07/14] Adjustments to the `FormsRepository` class --- .../forms/FormInteractionManager.java | 2 +- .../SubmissionsExportFormSubcommand.java | 2 +- .../forms/dao/FormsRepository.java | 40 ++++++++----------- 3 files changed, 19 insertions(+), 25 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java index cd59b794e..6c67f622a 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -150,7 +150,7 @@ public void handleModal(ModalInteractionEvent event, List values) } channel.sendMessageEmbeds(createSubmissionEmbed(form, values, event.getMember())).queue(msg -> { - formsRepo.logSubmission(event.getUser(), form, msg); + formsRepo.addSubmission(event.getUser(), form, msg); }); event.getHook() diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java index f28f2c716..e13cb4cc3 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java @@ -55,7 +55,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - Map submissions = formsRepo.getAllSubmissions(form); + Map submissions = formsRepo.getSubmissionsCountPerUser(form); JsonObject root = new JsonObject(); JsonObject details = new JsonObject(); JsonArray users = new JsonArray(); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index a7718bf40..e3ead8763 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -1,6 +1,5 @@ package net.discordjug.javabot.systems.staff_commands.forms.dao; -import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; @@ -13,7 +12,6 @@ import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.PreparedStatementCreator; import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; @@ -136,13 +134,13 @@ public List getAllForms(boolean closed) { * @param form a form to get submissions for * @return a map of users and the number of their submissions */ - public Map getAllSubmissions(FormData form) { + public Map getSubmissionsCountPerUser(FormData form) { Objects.requireNonNull(form); List users = jdbcTemplate.query("select * from `form_submissions` where `form_id` = ?", (rs, rowNum) -> new FormUser(rs.getLong("user_id"), rs.getString("user_name")), form.getId()); Map map = new HashMap<>(); for (FormUser user : users) { - map.compute(user, (t, u) -> u == null ? 1 : u + 1); + map.merge(user, 1, Integer::sum); } return Collections.unmodifiableMap(map); } @@ -151,7 +149,7 @@ public Map getAllSubmissions(FormData form) { * Get a form for given ID. * * @param formId form ID to query - * @return optional containing the form, or empty if the form was not found. + * @return optional form */ public Optional getForm(long formId) { try { @@ -175,7 +173,7 @@ public int getTotalSubmissionsCount(FormData form) { } /** - * Checks if an user already submitted the form. + * Checks if a user already submitted the form. * * @param user user to check * @param form form to check on @@ -199,32 +197,28 @@ public boolean hasSubmitted(User user, FormData form) { */ public void insertForm(@NonNull FormData data) { Objects.requireNonNull(data); - jdbcTemplate.update(new PreparedStatementCreator() { - - @Override - public PreparedStatement createPreparedStatement(Connection con) throws SQLException { - PreparedStatement statement = con.prepareStatement( - "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); - statement.setString(1, data.getTitle()); - statement.setString(2, data.getSubmitMessage()); - statement.setLong(3, data.getSubmitChannel()); - statement.setObject(4, data.getMessageId().orElse(null)); - statement.setObject(5, data.getMessageChannel().orElse(null)); - statement.setLong(6, data.getExpiration()); - statement.setBoolean(7, data.isOnetime()); - return statement; - } + jdbcTemplate.update(con -> { + PreparedStatement statement = con.prepareStatement( + "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); + statement.setString(1, data.getTitle()); + statement.setString(2, data.getSubmitMessage()); + statement.setLong(3, data.getSubmitChannel()); + statement.setObject(4, data.getMessageId().orElse(null)); + statement.setObject(5, data.getMessageChannel().orElse(null)); + statement.setLong(6, data.getExpiration()); + statement.setBoolean(7, data.isOnetime()); + return statement; }); } /** - * Log an user form submission in database. + * Add a user form submission to the database. * * @param user user to log * @param form form to log on * @param message message containing details about this user's submission */ - public void logSubmission(User user, FormData form, Message message) { + public void addSubmission(User user, FormData form, Message message) { Objects.requireNonNull(user); Objects.requireNonNull(form); jdbcTemplate.update(con -> { From 61d7e3612a985e6f630f7002bbf7d3684cb3e1ff Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 16:52:28 +0200 Subject: [PATCH 08/14] Fix javadoc spelling mistakes --- .../forms/commands/SubmissionsDeleteFormSubcommand.java | 2 +- .../systems/staff_commands/forms/dao/FormsRepository.java | 2 +- .../javabot/systems/staff_commands/forms/model/FormData.java | 2 +- .../javabot/systems/staff_commands/forms/model/FormUser.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java index c9082cb6f..7e2f4c756 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java @@ -30,7 +30,7 @@ public class SubmissionsDeleteFormSubcommand extends Subcommand implements AutoC */ public SubmissionsDeleteFormSubcommand(FormsRepository formsRepo) { this.formsRepo = formsRepo; - setCommandData(new SubcommandData("submissions-delete", "Deletes submissions of an user in the form") + setCommandData(new SubcommandData("submissions-delete", "Deletes submissions of a user in the form") .addOptions(new OptionData(OptionType.INTEGER, "form-id", "The ID of a form to get submissions for", true, true), new OptionData(OptionType.USER, "user", "User to delete submissions of", true))); } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index e3ead8763..e0934bc01 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -129,7 +129,7 @@ public List getAllForms(boolean closed) { } /** - * Get all submissions of this form in an user -> count map. + * Get all submissions of this form in a user -> count map. * * @param form a form to get submissions for * @return a map of users and the number of their submissions diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java index 933ab0a9b..3a7c0aabe 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -78,7 +78,7 @@ public boolean isAttached() { /** * Creates text components for use in the submission modal. * - * @return Lsit of layout components for use in the submission modal. + * @return List of layout components for use in the submission modal. */ public LayoutComponent[] createComponents() { LayoutComponent[] array = new LayoutComponent[fields.size()]; diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java index 2d515d687..12ae3a920 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java @@ -3,7 +3,7 @@ import java.util.Objects; /** - * Represents an user who submitted a form. + * Represents a user who submitted a form. */ public class FormUser { private final long id; From 21d9a420e057a6ddb990abfb6ae427038106b143 Mon Sep 17 00:00:00 2001 From: Defective Date: Wed, 17 Sep 2025 17:01:09 +0200 Subject: [PATCH 09/14] Make the `FormField` into a record --- .../forms/FormInteractionManager.java | 2 +- .../commands/AddFieldFormSubcommand.java | 9 +- .../commands/RemoveFieldFormSubcommand.java | 4 +- .../forms/dao/FormsRepository.java | 6 +- .../staff_commands/forms/model/FormField.java | 82 +------------------ 5 files changed, 11 insertions(+), 92 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java index 6c67f622a..f5181d1d7 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -268,7 +268,7 @@ private static MessageEmbed createSubmissionEmbed(FormData form, List= form.getFields().size()) { - event.getHook().sendMessage("Field index out of bounds").queue(); - return; - } - formsRepo.addField(form, createFormFieldFromEvent(event)); event.getHook().sendMessage("Added a new field to the form.").queue(); } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java index 6c190a748..99bcd2928 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java @@ -61,7 +61,7 @@ public void execute(SlashCommandInteractionEvent event) { formsRepo.removeField(form, index); - event.getHook().sendMessage("Removed field `" + form.getFields().get(index).getLabel() + "` from the form.") + event.getHook().sendMessage("Removed field `" + form.getFields().get(index).label() + "` from the form.") .queue(); } @@ -79,7 +79,7 @@ public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCo List choices = new ArrayList<>(); List fields = form.get().getFields(); for (int i = 0; i < fields.size(); i++) { - choices.add(new Choice(fields.get(i).getLabel(), i)); + choices.add(new Choice(fields.get(i).label(), i)); } event.replyChoices(choices).queue(); return; diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index e0934bc01..cfa697478 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -43,8 +43,8 @@ public void addField(FormData form, FormField field) { jdbcTemplate.update( "INSERT INTO FORM_FIELDS (FORM_ID, LABEL, MIN, MAX, PLACEHOLDER, REQUIRED, \"style\", INITIAL) " + "VALUES(?, ?, ?, ?, ?, ?, ?, ?)", - form.getId(), field.getLabel(), field.getMin(), field.getMax(), field.getPlaceholder(), - field.isRequired(), field.getStyle().name(), field.getValue()); + form.getId(), field.label(), field.min(), field.max(), field.placeholder(), field.required(), + field.style().name(), field.value()); } /** @@ -241,7 +241,7 @@ public void addSubmission(User user, FormData form, Message message) { public void removeField(FormData form, int index) { List fields = form.getFields(); if (index < 0 || index >= fields.size()) return; - jdbcTemplate.update("delete from `form_fields` where `id` = ?", fields.get(index).getId()); + jdbcTemplate.update("delete from `form_fields` where `id` = ?", fields.get(index).id()); } /** diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java index 666a22c1b..92e0fc915 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -6,40 +6,8 @@ /** * Represents a form field. */ -public class FormField { - private final long id; - private final String label; - private final int max; - private final int min; - private final String placeholder; - private final boolean required; - private final TextInputStyle style; - private final String value; - - /** - * Main constructor. - * - * @param label text field lable. - * @param max maximum characters allowed. - * @param min minimum characters allowed. - * @param placeholder text field placeholder. - * @param required whether or not this field is required. - * @param style text field style. One of {@link TextInputStyle} values. - * Case insensitive. - * @param value initial value of this text field. - * @param id unique ID of this field. - */ - public FormField(String label, int max, int min, String placeholder, boolean required, TextInputStyle style, - String value, long id) { - this.id = id; - this.label = label; - this.max = max; - this.min = min; - this.placeholder = placeholder; - this.required = required; - this.style = style; - this.value = value; - } +public record FormField(String label, int max, int min, String placeholder, boolean required, + TextInputStyle style, String value, long id) { /** * Create a standalone text input from this field. @@ -48,50 +16,8 @@ public FormField(String label, int max, int min, String placeholder, boolean req * @return text input ready to use in a modal. */ public TextInput createTextInput(String id) { - return TextInput.create(id, getLabel(), getStyle()).setRequiredRange(getMin(), getMax()) - .setPlaceholder(getPlaceholder()).setRequired(isRequired()).setValue(getValue()).build(); - } - - /** - * Get this field's unique ID. - * - * @return unique ID of the field. - */ - public long getId() { - return id; - } - - public String getLabel() { - return label; - } - - public int getMax() { - return max <= 0 ? 64 : max; - } - - public int getMin() { - return Math.max(0, min); - } - - public String getPlaceholder() { - return placeholder; - } - - /** - * Get the style of this field's text input. - * - * @return one of {@link TextInputStyle} values. - */ - public TextInputStyle getStyle() { - return style; - } - - public String getValue() { - return value; - } - - public boolean isRequired() { - return required; + return TextInput.create(id, label(), style()).setRequiredRange(min(), max()).setPlaceholder(placeholder()) + .setRequired(required()).setValue(value()).build(); } @Override From 3425fa0556defb2e6d33c995065ebf471ffafb8b Mon Sep 17 00:00:00 2001 From: Defective Date: Thu, 18 Sep 2025 12:24:29 +0200 Subject: [PATCH 10/14] Convert `FormData` into a record and use `Instants` for expiration Signed-off-by: Defective --- .../forms/FormInteractionManager.java | 46 ++++---- .../commands/AddFieldFormSubcommand.java | 4 +- .../forms/commands/AttachFormSubcommand.java | 8 +- .../forms/commands/CloseFormSubcommand.java | 4 +- .../forms/commands/CreateFormSubcommand.java | 16 ++- .../forms/commands/DeleteFormSubcommand.java | 2 +- .../forms/commands/DetachFormSubcommand.java | 4 +- .../forms/commands/DetailsFormSubcommand.java | 25 ++-- .../forms/commands/ModifyFormSubcommand.java | 32 +++--- .../commands/RemoveFieldFormSubcommand.java | 10 +- .../forms/commands/ReopenFormSubcommand.java | 4 +- .../forms/commands/ShowFormSubcommand.java | 4 +- .../SubmissionsDeleteFormSubcommand.java | 2 +- .../SubmissionsExportFormSubcommand.java | 4 +- .../forms/dao/FormsRepository.java | 58 +++++----- .../staff_commands/forms/model/FormData.java | 108 +++--------------- .../staff_commands/forms/model/FormField.java | 7 -- .../database/migrations/09-08-2025_forms.sql | 40 +++---- 18 files changed, 156 insertions(+), 222 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java index f5181d1d7..ec00186ce 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/FormInteractionManager.java @@ -83,7 +83,7 @@ public void closeForm(Guild guild, FormData form) { String cptId = btn.getId(); String[] split = ComponentIdBuilder.split(cptId); if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) - && split[1].equals(Long.toString(form.getId()))) { + && split[1].equals(Long.toString(form.id()))) { return btn.asDisabled(); } return btn; @@ -103,13 +103,13 @@ public void handleButton(ButtonInteractionEvent event, Button button) { FormData form = formOpt.get(); if (!checkNotClosed(form)) { event.reply("This form is not accepting new submissions.").setEphemeral(true).queue(); - if (!form.isClosed()) { + if (!form.closed()) { closeForm(event.getGuild(), form); } return; } - if (form.isOnetime() && formsRepo.hasSubmitted(event.getUser(), form)) { + if (form.onetime() && formsRepo.hasSubmitted(event.getUser(), form)) { event.reply("You have already submitted this form").setEphemeral(true).queue(); return; } @@ -136,12 +136,12 @@ public void handleModal(ModalInteractionEvent event, List values) return; } - if (form.isOnetime() && formsRepo.hasSubmitted(event.getUser(), form)) { + if (form.onetime() && formsRepo.hasSubmitted(event.getUser(), form)) { event.getHook().sendMessage("You have already submitted this form").queue(); return; } - TextChannel channel = event.getGuild().getTextChannelById(form.getSubmitChannel()); + TextChannel channel = event.getGuild().getTextChannelById(form.submitChannel()); if (channel == null) { event.getHook() .sendMessage("We couldn't receive your submission due to an error. Please contact server staff.") @@ -154,8 +154,7 @@ public void handleModal(ModalInteractionEvent event, List values) }); event.getHook() - .sendMessage( - form.getSubmitMessage() == null ? "Your submission was received!" : form.getSubmitMessage()) + .sendMessage(form.submitMessage() == null ? "Your submission was received!" : form.submitMessage()) .queue(); } @@ -198,7 +197,7 @@ public void reopenForm(Guild guild, FormData form) { String cptId = btn.getId(); String[] split = ComponentIdBuilder.split(cptId); if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID) - && split[1].equals(Long.toString(form.getId()))) { + && split[1].equals(Long.toString(form.id()))) { return btn.asEnabled(); } return btn; @@ -214,7 +213,7 @@ public void reopenForm(Guild guild, FormData form) { * @return submission modal to be presented to the user. */ public static Modal createFormModal(FormData form) { - Modal modal = Modal.create(ComponentIdBuilder.build(FORM_COMPONENT_ID, form.getId()), form.getTitle()) + Modal modal = Modal.create(ComponentIdBuilder.build(FORM_COMPONENT_ID, form.id()), form.title()) .addComponents(form.createComponents()).build(); return modal; } @@ -226,32 +225,31 @@ public static Modal createFormModal(FormData form) { * @return an optional containing expiration time, * {@link FormData#EXPIRATION_PERMANENT} if none given, or an empty * optional if it's invalid. + * @throws IllegalArgumentException if the date doesn't follow the format. */ - public static Optional parseExpiration(SlashCommandInteractionEvent event) { + public static Optional parseExpiration(SlashCommandInteractionEvent event) + throws IllegalArgumentException { String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString); - Optional expiration; + Optional expiration; if (expirationStr == null) { - expiration = Optional.of(FormData.EXPIRATION_PERMANENT); + expiration = Optional.empty(); } else { try { - expiration = Optional.of(FormInteractionManager.DATE_FORMAT.parse(expirationStr).getTime()); + expiration = Optional.of(FormInteractionManager.DATE_FORMAT.parse(expirationStr).toInstant()); } catch (ParseException e) { - event.getHook().sendMessage("Invalid date. You should follow the format `" - + FormInteractionManager.DATE_FORMAT_STRING + "`.").setEphemeral(true).queue(); - expiration = Optional.empty(); + throw new IllegalArgumentException("Invalid date. You should follow the format `" + + FormInteractionManager.DATE_FORMAT_STRING + "`."); } } - if (expiration.isPresent() && expiration.get() != FormData.EXPIRATION_PERMANENT - && expiration.get() < System.currentTimeMillis()) { - event.getHook().sendMessage("The expiration date shouldn't be in the past").setEphemeral(true).queue(); - return Optional.empty(); + if (expiration.isPresent() && expiration.get().isBefore(Instant.now())) { + throw new IllegalArgumentException("The expiration date shouldn't be in the past"); } return expiration; } private static boolean checkNotClosed(FormData data) { - if (data.isClosed() || data.hasExpired()) { + if (data.closed() || data.hasExpired()) { return false; } @@ -261,12 +259,12 @@ private static boolean checkNotClosed(FormData data) { private static MessageEmbed createSubmissionEmbed(FormData form, List values, Member author) { EmbedBuilder builder = new EmbedBuilder().setTitle("New form submission received") .setAuthor(author.getEffectiveName(), null, author.getEffectiveAvatarUrl()).setTimestamp(Instant.now()); - builder.addField("Sender", author.getAsMention(), true).addField("Title", form.getTitle(), true); + builder.addField("Sender", author.getAsMention(), true).addField("Title", form.title(), true); - int len = Math.min(values.size(), form.getFields().size()); + int len = Math.min(values.size(), form.fields().size()); for (int i = 0; i < len; i++) { ModalMapping mapping = values.get(i); - FormField field = form.getFields().get(i); + FormField field = form.fields().get(i); String value = mapping.getAsString(); builder.addField(field.label(), value == null ? "*Empty*" : "```\n" + value + "\n```", false); } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java index fef562da5..23a126b2c 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AddFieldFormSubcommand.java @@ -54,7 +54,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - if (form.getFields().size() >= 5) { + if (form.fields().size() >= 5) { event.getHook().sendMessage("Can't add more than 5 components to a form").queue(); return; } @@ -67,7 +67,7 @@ public void execute(SlashCommandInteractionEvent event) { public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { switch (target.getName()) { case "form-id" -> event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); case "style" -> event.replyChoices(Arrays.stream(TextInputStyle.values()).filter(t -> t != TextInputStyle.UNKNOWN) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java index abc9ef7b6..e42b5bdf9 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/AttachFormSubcommand.java @@ -70,7 +70,7 @@ public void execute(SlashCommandInteractionEvent event) { return; } - if (form.getFields().isEmpty()) { + if (form.fields().isEmpty()) { event.getHook().sendMessage("You can't attach a form with no fields.").queue(); return; } @@ -111,7 +111,7 @@ public void execute(SlashCommandInteractionEvent event) { public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { switch (target.getName()) { case "form-id" -> event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); case "button-style" -> event.replyChoices( Set.of(ButtonStyle.DANGER, ButtonStyle.PRIMARY, ButtonStyle.SECONDARY, ButtonStyle.SUCCESS).stream() @@ -125,9 +125,9 @@ private static void attachFormToMessage(Message message, String buttonLabel, But List rows = new ArrayList<>(message.getActionRows()); Button button = Button.of(style, - ComponentIdBuilder.build(FormInteractionManager.FORM_COMPONENT_ID, form.getId()), buttonLabel); + ComponentIdBuilder.build(FormInteractionManager.FORM_COMPONENT_ID, form.id()), buttonLabel); - if (form.isClosed() || form.hasExpired()) { + if (form.closed() || form.hasExpired()) { button = button.asDisabled(); } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java index 3c24e0079..e93400205 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CloseFormSubcommand.java @@ -50,7 +50,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - if (form.isClosed()) { + if (form.closed()) { event.reply("This form is already closed").setEphemeral(true).queue(); return; } @@ -65,7 +65,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms(false).stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms(false).stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java index 7060084d1..5958f143b 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/CreateFormSubcommand.java @@ -1,5 +1,6 @@ package net.discordjug.javabot.systems.staff_commands.forms.commands; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -44,14 +45,17 @@ public void execute(SlashCommandInteractionEvent event) { event.deferReply().setEphemeral(true).queue(); String expirationStr = event.getOption("expiration", null, OptionMapping::getAsString); - Optional expirationOpt = FormInteractionManager.parseExpiration(event); + Optional expirationOpt; + try { + expirationOpt = FormInteractionManager.parseExpiration(event); + } catch (IllegalArgumentException e) { + event.getHook().sendMessage(e.getMessage()).queue(); + return; + } - if (expirationOpt.isEmpty()) return; + Instant expiration = expirationOpt.orElse(null); - long expiration = expirationOpt.get(); - - long formId = System.currentTimeMillis(); - FormData form = new FormData(formId, List.of(), event.getOption("title", OptionMapping::getAsString), + FormData form = new FormData(0, List.of(), event.getOption("title", OptionMapping::getAsString), event.getOption("submit-channel", OptionMapping::getAsChannel).getIdLong(), event.getOption("submit-message", null, OptionMapping::getAsString), null, null, expiration, false, event.getOption("onetime", false, OptionMapping::getAsBoolean)); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java index feecf00f9..4a9ba514d 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DeleteFormSubcommand.java @@ -59,7 +59,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java index b3b8cac0d..6853f7bd0 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetachFormSubcommand.java @@ -68,7 +68,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } @@ -89,7 +89,7 @@ public static void detachFromMessage(FormData form, Guild guild) { String cptId = btn.getId(); String[] split = ComponentIdBuilder.split(cptId); if (split[0].equals(FormInteractionManager.FORM_COMPONENT_ID)) { - return !split[1].equals(Long.toString(form.getId())); + return !split[1].equals(Long.toString(form.id())); } } return true; diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java index 5cc7d283b..8a11df5fb 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/DetailsFormSubcommand.java @@ -61,37 +61,40 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) - .queue(); + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()).queue(); } private EmbedBuilder createFormDetailsEmbed(FormData form, Guild guild) { EmbedBuilder builder = new EmbedBuilder().setTitle("Form details"); - long id = form.getId(); + long id = form.id(); addCodeblockField(builder, "ID", id, true); builder.addField("Created at", String.format("", id / 1000L), true); String expiration; builder.addField("Expires at", - form.hasExpirationTime() ? String.format("", form.getExpiration() / 1000L) : "`Never`", true); + form.hasExpirationTime() ? String.format("", form.expiration().toEpochMilli() / 1000L) + : "`Never`", + true); - addCodeblockField(builder, "State", form.isClosed() ? "Closed" : form.hasExpired() ? "Expired" : "Open", false); + addCodeblockField(builder, "State", form.closed() ? "Closed" : form.hasExpired() ? "Expired" : "Open", false); builder.addField("Attached in", form.isAttached() ? "<#" + form.getMessageChannel().get() + ">" : "*Not attached*", true); builder.addField("Attached to", - form.isAttached() ? String.format("[Link](https://discord.com/channels/%s/%s/%s)", guild.getId(), - form.getMessageChannel().get(), form.getMessageId().get()) : "*Not attached*", + form.isAttached() + ? String.format("[Link](https://discord.com/channels/%s/%s/%s)", guild.getId(), + form.getMessageChannel().get(), form.getMessageId().get()) + : "*Not attached*", true); - builder.addField("Submissions channel", "<#" + form.getSubmitChannel() + ">", true); - builder.addField("Is one-time", form.isOnetime() ? ":white_check_mark:" : ":x:", true); + builder.addField("Submissions channel", "<#" + form.submitChannel() + ">", true); + builder.addField("Is one-time", form.onetime() ? ":white_check_mark:" : ":x:", true); addCodeblockField(builder, "Submission message", - form.getSubmitMessage() == null ? "Default" : form.getSubmitMessage(), true); + form.submitMessage() == null ? "Default" : form.submitMessage(), true); - addCodeblockField(builder, "Number of fields", form.getFields().size(), true); + addCodeblockField(builder, "Number of fields", form.fields().size(), true); addCodeblockField(builder, "Number of submissions", formsRepo.getTotalSubmissionsCount(form), true); return builder; diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java index 90c80a013..a9c937e5f 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ModifyFormSubcommand.java @@ -1,5 +1,6 @@ package net.discordjug.javabot.systems.staff_commands.forms.commands; +import java.time.Instant; import java.util.Optional; import net.discordjug.javabot.systems.staff_commands.forms.FormInteractionManager; @@ -55,24 +56,28 @@ public void execute(SlashCommandInteractionEvent event) { } FormData oldForm = formOpt.get(); - String title = event.getOption("title", oldForm.getTitle(), OptionMapping::getAsString); - long submitChannel = event.getOption("submit-channel", oldForm.getSubmitChannel(), OptionMapping::getAsLong); - String submitMessage = event.getOption("submit-message", oldForm.getSubmitMessage(), - OptionMapping::getAsString); - long expiration; + String title = event.getOption("title", oldForm.title(), OptionMapping::getAsString); + long submitChannel = event.getOption("submit-channel", oldForm.submitChannel(), OptionMapping::getAsLong); + String submitMessage = event.getOption("submit-message", oldForm.submitMessage(), OptionMapping::getAsString); + Instant expiration; if (event.getOption("expiration") == null) { - expiration = oldForm.getExpiration(); + expiration = oldForm.expiration(); } else { - Optional expirationOpt = FormInteractionManager.parseExpiration(event); - if (expirationOpt.isEmpty()) return; - expiration = expirationOpt.get(); + Optional expirationOpt; + try { + expirationOpt = FormInteractionManager.parseExpiration(event); + } catch (IllegalArgumentException e) { + event.getHook().sendMessage(e.getMessage()).queue(); + return; + } + expiration = expirationOpt.orElse(oldForm.expiration()); } - boolean onetime = event.getOption("onetime", oldForm.isOnetime(), OptionMapping::getAsBoolean); + boolean onetime = event.getOption("onetime", oldForm.onetime(), OptionMapping::getAsBoolean); - FormData newForm = new FormData(oldForm.getId(), oldForm.getFields(), title, submitChannel, submitMessage, + FormData newForm = new FormData(oldForm.id(), oldForm.fields(), title, submitChannel, submitMessage, oldForm.getMessageId().orElse(null), oldForm.getMessageChannel().orElse(null), expiration, - oldForm.isClosed(), onetime); + oldForm.closed(), onetime); formsRepo.updateForm(newForm); @@ -82,8 +87,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) - .queue(); + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()).queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java index 99bcd2928..13d22b588 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/RemoveFieldFormSubcommand.java @@ -47,12 +47,12 @@ public void execute(SlashCommandInteractionEvent event) { return; } FormData form = formOpt.get(); - if (index < 0 || index >= form.getFields().size()) { + if (index < 0 || index >= form.fields().size()) { event.getHook().sendMessage("Field index out of bounds.").queue(); return; } - if (form.isAttached() && form.getFields().size() <= 1) { + if (form.isAttached() && form.fields().size() <= 1) { event.getHook().sendMessage( "Can't remove the last field from an attached form. Detach the form before removing the field") .queue(); @@ -61,7 +61,7 @@ public void execute(SlashCommandInteractionEvent event) { formsRepo.removeField(form, index); - event.getHook().sendMessage("Removed field `" + form.getFields().get(index).label() + "` from the form.") + event.getHook().sendMessage("Removed field `" + form.fields().get(index).label() + "` from the form.") .queue(); } @@ -69,7 +69,7 @@ public void execute(SlashCommandInteractionEvent event) { public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { switch (target.getName()) { case "form-id" -> event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); case "field" -> { Long formId = event.getOption("form-id", OptionMapping::getAsLong); @@ -77,7 +77,7 @@ public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCo Optional form = formsRepo.getForm(formId); if (form.isPresent()) { List choices = new ArrayList<>(); - List fields = form.get().getFields(); + List fields = form.get().fields(); for (int i = 0; i < fields.size(); i++) { choices.add(new Choice(fields.get(i).label(), i)); } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java index 3b130773b..92bb867da 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ReopenFormSubcommand.java @@ -50,7 +50,7 @@ public void execute(SlashCommandInteractionEvent event) { } FormData form = formOpt.get(); - if (!form.isClosed()) { + if (!form.closed()) { event.reply("This form is already opened").setEphemeral(true).queue(); return; } @@ -65,7 +65,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms(true).stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms(true).stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java index c701576d1..10b004bc0 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/ShowFormSubcommand.java @@ -43,7 +43,7 @@ public void execute(SlashCommandInteractionEvent event) { return; } FormData form = formOpt.get(); - if (form.getFields().isEmpty()) { + if (form.fields().isEmpty()) { event.reply("You can't open a form with no fields").setEphemeral(true).queue(); return; } @@ -53,7 +53,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java index 7e2f4c756..36f7ef470 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsDeleteFormSubcommand.java @@ -54,7 +54,7 @@ public void execute(SlashCommandInteractionEvent event) { @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java index e13cb4cc3..83e385bd4 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java @@ -69,13 +69,13 @@ public void execute(SlashCommandInteractionEvent event) { root.add("users", users); root.add("details", details); event.getHook().sendFiles(FileUpload.fromData(gson.toJson(root).getBytes(StandardCharsets.UTF_8), - "submissions_" + form.getId() + ".json")).queue(); + "submissions_" + form.id() + ".json")).queue(); } @Override public void handleAutoComplete(CommandAutoCompleteInteractionEvent event, AutoCompleteQuery target) { event.replyChoices( - formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.getId())).toList()) + formsRepo.getAllForms().stream().map(form -> new Choice(form.toString(), form.id())).toList()) .queue(); } } diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java index cfa697478..e644475b8 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/dao/FormsRepository.java @@ -3,6 +3,8 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -41,9 +43,9 @@ public class FormsRepository { */ public void addField(FormData form, FormField field) { jdbcTemplate.update( - "INSERT INTO FORM_FIELDS (FORM_ID, LABEL, MIN, MAX, PLACEHOLDER, REQUIRED, \"style\", INITIAL) " + "INSERT INTO form_fields (form_id, label, min, max, placeholder, \"required\", \"style\", initial) " + "VALUES(?, ?, ?, ?, ?, ?, ?, ?)", - form.getId(), field.label(), field.min(), field.max(), field.placeholder(), field.required(), + form.id(), field.label(), field.min(), field.max(), field.placeholder(), field.required(), field.style().name(), field.value()); } @@ -59,7 +61,7 @@ public void attachForm(FormData form, MessageChannel channel, Message message) { Objects.requireNonNull(channel); Objects.requireNonNull(message); jdbcTemplate.update("update `forms` set `message_id` = ?, `message_channel` = ? where `form_id` = ?", - message.getId(), channel.getId(), form.getId()); + message.getId(), channel.getId(), form.id()); } /** @@ -68,7 +70,7 @@ public void attachForm(FormData form, MessageChannel channel, Message message) { * @param form form to close */ public void closeForm(FormData form) { - jdbcTemplate.update("update `forms` set `closed` = true where `form_id` = ?", form.getId()); + jdbcTemplate.update("update `forms` set `closed` = true where `form_id` = ?", form.id()); } /** @@ -77,7 +79,7 @@ public void closeForm(FormData form) { * @param form form to delete */ public void deleteForm(FormData form) { - jdbcTemplate.update("delete from `forms` where `form_id` = ?", form.getId()); + jdbcTemplate.update("delete from `forms` where `form_id` = ?", form.id()); } /** @@ -90,7 +92,7 @@ public void deleteForm(FormData form) { public int deleteSubmissions(FormData form, User user) { Objects.requireNonNull(form); Objects.requireNonNull(user); - return jdbcTemplate.update("delete from `form_submissions` where `form_id` = ? and `user_id` = ?", form.getId(), + return jdbcTemplate.update("delete from `form_submissions` where `form_id` = ? and `user_id` = ?", form.id(), user.getIdLong()); } @@ -102,7 +104,7 @@ public int deleteSubmissions(FormData form, User user) { public void detachForm(FormData form) { Objects.requireNonNull(form); jdbcTemplate.update("update `forms` set `message_id` = NULL, `message_channel` = NULL where `form_id` = ?", - form.getId()); + form.id()); } /** @@ -137,7 +139,7 @@ public List getAllForms(boolean closed) { public Map getSubmissionsCountPerUser(FormData form) { Objects.requireNonNull(form); List users = jdbcTemplate.query("select * from `form_submissions` where `form_id` = ?", - (rs, rowNum) -> new FormUser(rs.getLong("user_id"), rs.getString("user_name")), form.getId()); + (rs, rowNum) -> new FormUser(rs.getLong("user_id"), rs.getString("user_name")), form.id()); Map map = new HashMap<>(); for (FormUser user : users) { map.merge(user, 1, Integer::sum); @@ -169,7 +171,7 @@ public Optional getForm(long formId) { public int getTotalSubmissionsCount(FormData form) { Objects.requireNonNull(form); return jdbcTemplate.queryForObject("select count(*) from `form_submissions` where `form_id` = ?", - (rs, rowNum) -> rs.getInt(1), form.getId()); + (rs, rowNum) -> rs.getInt(1), form.id()); } /** @@ -184,7 +186,7 @@ public boolean hasSubmitted(User user, FormData form) { try { return jdbcTemplate.queryForObject( "select * from `form_submissions` where `user_id` = ? and `form_id` = ? limit 1", - (rs, rowNum) -> true, user.getIdLong(), form.getId()); + (rs, rowNum) -> true, user.getIdLong(), form.id()); } catch (EmptyResultDataAccessException e) { return false; } @@ -200,13 +202,14 @@ public void insertForm(@NonNull FormData data) { jdbcTemplate.update(con -> { PreparedStatement statement = con.prepareStatement( "insert into `forms` (title, submit_message, submit_channel, message_id, message_channel, expiration, onetime) values (?, ?, ?, ?, ?, ?, ?)"); - statement.setString(1, data.getTitle()); - statement.setString(2, data.getSubmitMessage()); - statement.setLong(3, data.getSubmitChannel()); + statement.setString(1, data.title()); + statement.setString(2, data.submitMessage()); + statement.setLong(3, data.submitChannel()); statement.setObject(4, data.getMessageId().orElse(null)); statement.setObject(5, data.getMessageChannel().orElse(null)); - statement.setLong(6, data.getExpiration()); - statement.setBoolean(7, data.isOnetime()); + statement.setTimestamp(6, + data.hasExpirationTime() ? new Timestamp(data.expiration().toEpochMilli()) : null); + statement.setBoolean(7, data.onetime()); return statement; }); } @@ -226,7 +229,7 @@ public void addSubmission(User user, FormData form, Message message) { "insert into `form_submissions` (`message_id`, `user_id`, `form_id`, `user_name`) values (?, ?, ?, ?)"); statement.setLong(1, message.getIdLong()); statement.setLong(2, user.getIdLong()); - statement.setLong(3, form.getId()); + statement.setLong(3, form.id()); statement.setString(4, user.getName()); return statement; }); @@ -239,7 +242,7 @@ public void addSubmission(User user, FormData form, Message message) { * @param index index of the field to remove */ public void removeField(FormData form, int index) { - List fields = form.getFields(); + List fields = form.fields(); if (index < 0 || index >= fields.size()) return; jdbcTemplate.update("delete from `form_fields` where `id` = ?", fields.get(index).id()); } @@ -250,7 +253,7 @@ public void removeField(FormData form, int index) { * @param form form to re-open */ public void reopenForm(FormData form) { - jdbcTemplate.update("update `forms` set `closed` = false where `form_id` = ?", form.getId()); + jdbcTemplate.update("update `forms` set `closed` = false where `form_id` = ?", form.id()); } /** @@ -264,12 +267,13 @@ public void updateForm(FormData newData) { jdbcTemplate.update(con -> { PreparedStatement statement = con.prepareStatement( "update `forms` set `title` = ?, `submit_channel` = ?, `submit_message` = ?, `expiration` = ?, `onetime` = ? where `form_id` = ?"); - statement.setString(1, newData.getTitle()); - statement.setLong(2, newData.getSubmitChannel()); - statement.setString(3, newData.getSubmitMessage()); - statement.setLong(4, newData.getExpiration()); - statement.setBoolean(5, newData.isOnetime()); - statement.setLong(6, newData.getId()); + statement.setString(1, newData.title()); + statement.setLong(2, newData.submitChannel()); + statement.setString(3, newData.submitMessage()); + statement.setTimestamp(4, + newData.hasExpirationTime() ? new Timestamp(newData.expiration().toEpochMilli()) : null); + statement.setBoolean(5, newData.onetime()); + statement.setLong(6, newData.id()); return statement; }); } @@ -284,9 +288,11 @@ private static FormData read(ResultSet rs, List fields) throws SQLExc if (rs.wasNull()) messageId = null; Long messageChannel = rs.getLong("message_channel"); if (rs.wasNull()) messageChannel = null; + Timestamp timestamp = rs.getTimestamp("expiration"); + Instant expiration = timestamp == null ? null : timestamp.toInstant(); return new FormData(rs.getLong("form_id"), fields, rs.getString("title"), rs.getLong("submit_channel"), - rs.getString("submit_message"), messageId, messageChannel, rs.getLong("expiration"), - rs.getBoolean("closed"), rs.getBoolean("onetime")); + rs.getString("submit_message"), messageId, messageChannel, expiration, rs.getBoolean("closed"), + rs.getBoolean("onetime")); } private static FormField readField(ResultSet rs) throws SQLException { diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java index 3a7c0aabe..c86a977fd 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -1,6 +1,6 @@ package net.discordjug.javabot.systems.staff_commands.forms.model; -import java.util.Collections; +import java.time.Instant; import java.util.Date; import java.util.List; import java.util.Objects; @@ -13,7 +13,9 @@ /** * Class containing information about a form. */ -public class FormData { +// TODO `Optional` getter for the submit message +public record FormData(long id, List fields, String title, long submitChannel, String submitMessage, + Long messageId, Long messageChannel, Instant expiration, boolean closed, boolean onetime) { /** * Setting {@link FormData#expiration} to this value indicates, that the form @@ -21,54 +23,9 @@ public class FormData { */ public static final long EXPIRATION_PERMANENT = -1; - private final boolean closed; - private final long expiration; - private final List fields; - private final long id; - private final Long messageId; - private final Long messageChannel; - private final boolean onetime; - private final long submitChannel; - private final String submitMessage; - private final String title; - - /** - * Main constructor. - * - * @param id The id of this form. The id should be equal to - * timestamp of creation of this form. - * @param fields List of text inputs of this form. - * @param title Form title shown in the submission modal and in various - * commands. - * @param submitChannel Target channel where the form submissions will be sent. - * @param submitMessage A message presented to the user after they successfully - * submit the form. - * @param messageId ID of the message this form is attached to. A null - * value indicates that this form is not attached to any - * message. - * @param messageChannel Channel of the message this form is attached to. A null - * value indicates that this form is not attached. - * @param expiration Time after which this form will not accept further - * submissions. Value of - * {@link FormData#EXPIRATION_PERMANENT} indicates that - * this form will never expire. - * @param closed Closed state of this form. A closed form doesn't accept - * further submissions and has its components disabled. - * @param onetime Whether or not this form accepts one submission per - * user. - */ - public FormData(long id, List fields, String title, long submitChannel, String submitMessage, - Long messageId, Long messageChannel, long expiration, boolean closed, boolean onetime) { - this.id = id; - this.fields = Objects.requireNonNull(fields); - this.title = Objects.requireNonNull(title); - this.submitChannel = submitChannel; - this.submitMessage = submitMessage; - this.messageId = messageId; - this.messageChannel = messageChannel; - this.expiration = expiration; - this.closed = closed; - this.onetime = onetime; + public FormData { + Objects.requireNonNull(title); + fields = List.copyOf(fields); } public boolean isAttached() { @@ -88,45 +45,13 @@ public LayoutComponent[] createComponents() { return array; } - public long getExpiration() { - return expiration; - } - - public List getFields() { - return fields == null ? Collections.emptyList() : fields; - } - - public long getId() { - return id; - } - - public Optional getMessageChannel() { - return Optional.ofNullable(messageChannel); - } - - public Optional getMessageId() { - return Optional.ofNullable(messageId); - } - - public long getSubmitChannel() { - return submitChannel; - } - - public String getSubmitMessage() { - return submitMessage; - } - - public String getTitle() { - return title; - } - /** * Checks if the form can expire. * * @return true if this form has an expiration time. */ public boolean hasExpirationTime() { - return expiration > 0; + return expiration != null; } /** @@ -136,15 +61,15 @@ public boolean hasExpirationTime() { * can't expire. */ public boolean hasExpired() { - return hasExpirationTime() && expiration < System.currentTimeMillis(); + return hasExpirationTime() && expiration.isBefore(Instant.now()); } - public boolean isClosed() { - return closed; + public Optional getMessageId() { + return Optional.ofNullable(messageId); } - public boolean isOnetime() { - return onetime; + public Optional getMessageChannel() { + return Optional.ofNullable(messageChannel); } @Override @@ -152,12 +77,13 @@ public String toString() { String prefix; if (closed) { prefix = "Closed"; - } else if (expiration == EXPIRATION_PERMANENT) { + } else if (!hasExpirationTime()) { prefix = "Permanent"; - } else if (expiration < System.currentTimeMillis()) { + } else if (hasExpired()) { prefix = "Expired"; } else { - prefix = FormInteractionManager.DATE_FORMAT.format(new Date(expiration)) + " UTC"; + // TODO change how date and time is formatted + prefix = FormInteractionManager.DATE_FORMAT.format(new Date(expiration.toEpochMilli())) + " UTC"; } return String.format("[%s] %s", prefix, title); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java index 92e0fc915..ce11fec19 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -19,11 +19,4 @@ public TextInput createTextInput(String id) { return TextInput.create(id, label(), style()).setRequiredRange(min(), max()).setPlaceholder(placeholder()) .setRequired(required()).setValue(value()).build(); } - - @Override - public String toString() { - return "FormField [label=" + label + ", max=" + max + ", min=" + min + ", placeholder=" + placeholder - + ", required=" + required + ", style=" + style + ", value=" + value + "]"; - } - } diff --git a/src/main/resources/database/migrations/09-08-2025_forms.sql b/src/main/resources/database/migrations/09-08-2025_forms.sql index e3d1d55bb..32dc7c389 100644 --- a/src/main/resources/database/migrations/09-08-2025_forms.sql +++ b/src/main/resources/database/migrations/09-08-2025_forms.sql @@ -5,34 +5,34 @@ CREATE TABLE forms ( submit_channel BIGINT NOT NULL, message_id BIGINT DEFAULT NULL, message_channel BIGINT DEFAULT NULL, - expiration BIGINT NOT NULL DEFAULT -1, + expiration TIMESTAMP DEFAULT NULL, closed BOOLEAN NOT NULL DEFAULT FALSE, onetime BOOLEAN NOT NULL DEFAULT FALSE, PRIMARY KEY (form_id) ); -CREATE TABLE FORM_FIELDS ( - ID BIGINT NOT NULL AUTO_INCREMENT, - FORM_ID BIGINT NOT NULL, - LABEL CHARACTER VARYING NOT NULL, - MIN INTEGER DEFAULT 0 NOT NULL, - MAX INTEGER DEFAULT 16 NOT NULL, - PLACEHOLDER CHARACTER VARYING, - REQUIRED BOOLEAN DEFAULT FALSE NOT NULL, +CREATE TABLE form_fields ( + id BIGINT NOT NULL AUTO_INCREMENT, + form_id BIGINT NOT NULL, + label VARCHAR NOT NULL, + min INTEGER DEFAULT 0 NOT NULL, + max INTEGER DEFAULT 16 NOT NULL, + placeholder VARCHAR, + "required" BOOLEAN DEFAULT FALSE NOT NULL, "style" ENUM('SHORT', 'PARAGRAPH') DEFAULT 'SHORT' NOT NULL, - INITIAL CHARACTER VARYING, - PRIMARY KEY (ID), - FOREIGN KEY (FORM_ID) REFERENCES FORMS(FORM_ID) ON DELETE CASCADE ON UPDATE RESTRICT + initial VARCHAR DEFAULT NULL, + PRIMARY KEY (id), + FOREIGN KEY (form_id) REFERENCES forms(form_id) ON DELETE CASCADE ON UPDATE RESTRICT ); -CREATE TABLE FORM_SUBMISSIONS ( - ID BIGINT NOT NULL AUTO_INCREMENT, - MESSAGE_ID BIGINT NOT NULL, - USER_ID BIGINT NOT NULL, - FORM_ID BIGINT NOT NULL, - USER_NAME VARCHAR NOT NULL, - PRIMARY KEY (ID), - FOREIGN KEY (FORM_ID) REFERENCES FORMS(FORM_ID) ON DELETE CASCADE ON UPDATE RESTRICT +CREATE TABLE form_submissions ( + id BIGINT NOT NULL AUTO_INCREMENT, + message_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + form_id BIGINT NOT NULL, + user_name VARCHAR NOT NULL, + PRIMARY KEY (id), + FOREIGN KEY (form_id) REFERENCES FORMS(form_id) ON DELETE CASCADE ON UPDATE RESTRICT ); CREATE INDEX FORM_SUBMISSIONS_USER_ID_IDX ON form_submissions (user_id,form_id); From fdd328e5fb89f140afabd164e2e7f8f2776e9e7e Mon Sep 17 00:00:00 2001 From: Defective Date: Sat, 20 Sep 2025 11:01:56 +0200 Subject: [PATCH 11/14] Form field javadoc --- .../javabot/systems/staff_commands/forms/model/FormField.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java index ce11fec19..586137a2a 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -5,12 +5,14 @@ /** * Represents a form field. + * Form fields are used to store data about text inputs presented to the user in the form modal. + * Each form can have up to 5 fields. */ public record FormField(String label, int max, int min, String placeholder, boolean required, TextInputStyle style, String value, long id) { /** - * Create a standalone text input from this field. + * Create a text input from this field. * * @param id ID of this text input. * @return text input ready to use in a modal. From 123282b833599f2dea424af74f747d88328b60d3 Mon Sep 17 00:00:00 2001 From: Defective Date: Sat, 20 Sep 2025 11:04:47 +0200 Subject: [PATCH 12/14] Convert `FormUser` to a `record` --- .../SubmissionsExportFormSubcommand.java | 6 +-- .../staff_commands/forms/model/FormUser.java | 51 +++++-------------- 2 files changed, 17 insertions(+), 40 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java index 83e385bd4..9b320ff60 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/SubmissionsExportFormSubcommand.java @@ -61,10 +61,10 @@ public void execute(SlashCommandInteractionEvent event) { JsonArray users = new JsonArray(); for (Entry entry : submissions.entrySet()) { JsonObject uobj = new JsonObject(); - uobj.addProperty("username", entry.getKey().getUsername()); + uobj.addProperty("username", entry.getKey().username()); uobj.addProperty("submissions", entry.getValue()); - details.add(Long.toString(entry.getKey().getId()), uobj); - users.add(entry.getKey().getUsername()); + details.add(Long.toString(entry.getKey().id()), uobj); + users.add(entry.getKey().username()); } root.add("users", users); root.add("details", details); diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java index 12ae3a920..62de400ed 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java @@ -1,44 +1,21 @@ package net.discordjug.javabot.systems.staff_commands.forms.model; -import java.util.Objects; - /** * Represents a user who submitted a form. */ -public class FormUser { - private final long id; - private final String username; - - /** - * The main constructor. - * - * @param id user's id - * @param username user's username - */ - public FormUser(long id, String username) { - this.id = id; - this.username = username; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) return true; - if (obj == null || getClass() != obj.getClass()) return false; - FormUser other = (FormUser) obj; - return id == other.id && Objects.equals(username, other.username); - } - - public long getId() { - return id; - } - - public String getUsername() { - return username; - } - - @Override - public int hashCode() { - return Objects.hash(id, username); - } +public record FormUser(long id, String username) { + +// @Override +// public boolean equals(Object obj) { +// if (this == obj) return true; +// if (obj == null || getClass() != obj.getClass()) return false; +// FormUser other = (FormUser) obj; +// return id == other.id && Objects.equals(username, other.username); +// } + +// @Override +// public int hashCode() { +// return Objects.hash(id, username); +// } } From 7fccf8e2b2a59cee7caa41edcc8d18b95c8df79d Mon Sep 17 00:00:00 2001 From: Defective Date: Sat, 20 Sep 2025 11:07:41 +0200 Subject: [PATCH 13/14] `FormCommand` javadoc --- .../systems/staff_commands/forms/commands/FormCommand.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java index 2a2dc94cf..91529707e 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/commands/FormCommand.java @@ -5,7 +5,8 @@ import xyz.dynxsty.dih4jda.interactions.commands.application.SlashCommand; /** - * The `/form` command. + * The {@code /form} command. This is the base command. It holds subcommands + * used to manage forms and their submissions. */ public class FormCommand extends SlashCommand { From 385820dced20941cc5a747cab3cfe2aa9e5f0f5c Mon Sep 17 00:00:00 2001 From: Defective Date: Sat, 20 Sep 2025 11:27:29 +0200 Subject: [PATCH 14/14] Fix checkstyle --- .../staff_commands/forms/model/FormData.java | 26 ++++++++++++ .../staff_commands/forms/model/FormField.java | 40 ++++++++++++++++--- .../staff_commands/forms/model/FormUser.java | 3 ++ 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java index c86a977fd..dacd9fe19 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormData.java @@ -12,6 +12,26 @@ /** * Class containing information about a form. + * + * @param id the form ID. + * @param fields a list of text input fields associated with this form. + * A form can only hold a maximum of 5 fields at a time. + * @param title form title used in the modal displayed to the user. + * @param submitChannel ID of the channel the form submissions are sent to. + * @param submitMessage message displayed to the user once they submit the + * form. + * @param messageId ID of the message this form is attached to. null if the + * form is not attached to any message. + * @param messageChannel channel of the message this form is attached to. null + * if the form is not attached to any message. + * @param expiration time after which this user won't accept any further + * submissions. null to indicate that the form has no + * expiration date. + * @param closed closed state of this form. If the form is closed, it + * doesn't accept further submissions, even if it's + * expired. + * @param onetime onetime state of this form. If it's true, the form only + * accepts one submission per user. */ // TODO `Optional` getter for the submit message public record FormData(long id, List fields, String title, long submitChannel, String submitMessage, @@ -23,9 +43,15 @@ public record FormData(long id, List fields, String title, long submi */ public static final long EXPIRATION_PERMANENT = -1; + /** + * The main constructor. + */ public FormData { Objects.requireNonNull(title); fields = List.copyOf(fields); + if (fields.size() > 5) { + throw new IllegalArgumentException("fields.size() > 5"); + } } public boolean isAttached() { diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java index 586137a2a..77ea4ee75 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormField.java @@ -1,15 +1,45 @@ package net.discordjug.javabot.systems.staff_commands.forms.model; +import java.util.Objects; + import net.dv8tion.jda.api.interactions.components.text.TextInput; import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; /** - * Represents a form field. - * Form fields are used to store data about text inputs presented to the user in the form modal. - * Each form can have up to 5 fields. + * Represents a form field. Form fields are used to store data about text inputs + * presented to the user in the form modal. Each form can have up to 5 fields. + * + * @param label field label. + * @param max maximum number of characters allowed to be entered in this + * field. + * @param min minimum number of characters required. Setting min to a + * value greater than 0 will make this field effectively + * required, even if the {@code required} parameter is set to + * false. + * @param placeholder field placeholder. Use null to use any placeholder. + * @param required whether or not the user has to type something in this + * field. + * @param style text input style. + * @param value initial field value. Can be null to indicate no inital + * value. + * @param id form id. */ -public record FormField(String label, int max, int min, String placeholder, boolean required, - TextInputStyle style, String value, long id) { +public record FormField(String label, int max, int min, String placeholder, boolean required, TextInputStyle style, + String value, long id) { + + /** + * The main constructor. + */ + public FormField { + Objects.requireNonNull(label); + if (min < 0) throw new IllegalArgumentException("min < 0"); + + if (max < 1) throw new IllegalArgumentException("max < 1"); + + if (max < min) throw new IllegalArgumentException("max < min"); + + Objects.requireNonNull(style); + } /** * Create a text input from this field. diff --git a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java index 62de400ed..5e0a15208 100644 --- a/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java +++ b/src/main/java/net/discordjug/javabot/systems/staff_commands/forms/model/FormUser.java @@ -2,6 +2,9 @@ /** * Represents a user who submitted a form. + * + * @param id user's ID. + * @param username user's Discord username. */ public record FormUser(long id, String username) {