diff --git a/bot-api/build.gradle.kts b/bot-api/build.gradle.kts index 2a62bef..ffda586 100644 --- a/bot-api/build.gradle.kts +++ b/bot-api/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("java-library") + id("java-test-fixtures") id("chat-bot.java-conventions") } diff --git a/bot-api/src/main/java/module-info.java b/bot-api/src/main/java/module-info.java index 7209c31..37c09f5 100644 --- a/bot-api/src/main/java/module-info.java +++ b/bot-api/src/main/java/module-info.java @@ -2,7 +2,7 @@ import eu.jonahbauer.chat.bot.api.ChatBot; module eu.jonahbauer.chat.bot.api { exports eu.jonahbauer.chat.bot.api; - exports eu.jonahbauer.chat.bot.impl to eu.jonahbauer.chat.server; + exports eu.jonahbauer.chat.bot.impl to eu.jonahbauer.chat.server, eu.jonahbauer.chat.bot.api.fixtures; requires transitive eu.jonahbauer.chat.bot.config; requires static transitive org.jetbrains.annotations; diff --git a/bot-api/src/testFixtures/java/eu/jonahbauer/chat/bot/test/ChatBotFactoryAccess.java b/bot-api/src/testFixtures/java/eu/jonahbauer/chat/bot/test/ChatBotFactoryAccess.java new file mode 100644 index 0000000..afa6693 --- /dev/null +++ b/bot-api/src/testFixtures/java/eu/jonahbauer/chat/bot/test/ChatBotFactoryAccess.java @@ -0,0 +1,21 @@ +package eu.jonahbauer.chat.bot.test; + +import eu.jonahbauer.chat.bot.api.ChatBot; +import eu.jonahbauer.chat.bot.config.BotConfig; +import eu.jonahbauer.chat.bot.impl.ChatBotFactory; +import org.jetbrains.annotations.NotNull; + +import java.util.function.Supplier; + +public final class ChatBotFactoryAccess { + + private ChatBotFactoryAccess() {} + + public static T create(@NotNull BotConfig config, @NotNull Supplier supplier) { + return ScopedValue.where(ChatBotFactory.BOT_CONFIG, config).get(supplier); + } + + public static T create(@NotNull Supplier supplier) { + return create(BotConfig.EMPTY, supplier); + } +} diff --git a/bot-api/src/testFixtures/java/eu/jonahbauer/chat/bot/test/MockChat.java b/bot-api/src/testFixtures/java/eu/jonahbauer/chat/bot/test/MockChat.java new file mode 100644 index 0000000..65877dd --- /dev/null +++ b/bot-api/src/testFixtures/java/eu/jonahbauer/chat/bot/test/MockChat.java @@ -0,0 +1,27 @@ +package eu.jonahbauer.chat.bot.test; + +import eu.jonahbauer.chat.bot.api.Chat; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; + +public class MockChat implements Chat { + private final @NotNull List<@NotNull Message> messages = new ArrayList<>(); + + @Override + public boolean send(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId) { + messages.add(new Message(channel, name, message, bottag, publicId)); + return true; + } + + public @NotNull List<@NotNull Message> getMessages() { + return messages; + } + + public record Message(@NotNull String channel, @NotNull String name, @NotNull String message, boolean bottag, boolean publicId) { + public Message(@NotNull String channel, @NotNull String name, @NotNull String message) { + this(channel, name, message, true, true); + } + } +} diff --git a/bot-api/src/testFixtures/java/module-info.java b/bot-api/src/testFixtures/java/module-info.java new file mode 100644 index 0000000..e739b44 --- /dev/null +++ b/bot-api/src/testFixtures/java/module-info.java @@ -0,0 +1,7 @@ +module eu.jonahbauer.chat.bot.api.fixtures { + exports eu.jonahbauer.chat.bot.test; + + requires eu.jonahbauer.chat.bot.api; + requires eu.jonahbauer.chat.bot.config; + requires static transitive org.jetbrains.annotations; +} \ No newline at end of file diff --git a/bots/pizza-bot/build.gradle.kts b/bots/pizza-bot/build.gradle.kts new file mode 100644 index 0000000..65604fa --- /dev/null +++ b/bots/pizza-bot/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("chat-bot.bot-conventions") +} + +group = "eu.jonahbauer.chat.bots" + +dependencies { + implementation(project(":bot-database")) + testImplementation(testFixtures(project(":bot-api"))) +} \ No newline at end of file diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/PizzaBot.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/PizzaBot.java new file mode 100644 index 0000000..da34d05 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/PizzaBot.java @@ -0,0 +1,692 @@ +package eu.jonahbauer.chat.bot.pizza; + +import eu.jonahbauer.chat.bot.api.ChatBot; +import eu.jonahbauer.chat.bot.api.Message.Post; +import eu.jonahbauer.chat.bot.config.BotConfig; +import eu.jonahbauer.chat.bot.database.Database; +import eu.jonahbauer.chat.bot.database.NonUniqueResultException; +import eu.jonahbauer.chat.bot.pizza.model.*; +import eu.jonahbauer.chat.bot.pizza.model.table.Order; +import eu.jonahbauer.chat.bot.pizza.model.table.OrderItem; +import eu.jonahbauer.chat.bot.pizza.model.table.Pizza; +import eu.jonahbauer.chat.bot.pizza.model.table.User; +import eu.jonahbauer.chat.bot.pizza.model.view.OrderDetail; +import eu.jonahbauer.chat.bot.pizza.model.view.OrderItemDetail; +import eu.jonahbauer.chat.bot.pizza.util.ArgumentParser; +import eu.jonahbauer.chat.bot.pizza.util.Pair; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +import java.sql.SQLException; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static eu.jonahbauer.chat.bot.pizza.util.ArgumentParser.*; +import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.*; +import static java.util.FormatProcessor.FMT; + +@Slf4j +public class PizzaBot extends ChatBot { + private static final @NotNull ScopedValue ANNOYANCE = ScopedValue.newInstance(); + private static final @NotNull ScopedValue USER = ScopedValue.newInstance(); + + private static final @NotNull BotConfig.Key.OfStringArray PRIMARY_CHANNELS = BotConfig.Key.ofStringArray("primary_channels"); + private static final @NotNull BotConfig.Key.OfString DATABASE = BotConfig.Key.ofString("database"); + + private static final @NotNull DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("HH:mm") + .withZone(ZoneId.of("Europe/Berlin")) + .withLocale(Locale.GERMANY); + private static final @NotNull List<@NotNull String> ANNOYED_RESPONSES = List.of( + "Es ist so dunkel, ich kann dich nicht hören.", + "Jetzt schrei doch nicht so!", + "Fresse!" + ); + + private final @Nullable Set<@NotNull String> primaryChannels; + private final @NotNull PizzaService service; + + public PizzaBot() { + super("Marek"); + this.primaryChannels = getConfig().get(PRIMARY_CHANNELS).map(Set::copyOf).orElse(null); + this.service = new PizzaService(new Database(getConfig().require(DATABASE))); + } + + @VisibleForTesting + PizzaBot(@Nullable Set<@NotNull String> primaryChannels, @NotNull Database database) { + super("Marek"); + this.primaryChannels = primaryChannels; + this.service = new PizzaService(database); + } + + @Override + protected void onMessage(@NotNull Post post) { + var message = post.message(); + if (message.length() < 6) return; + + var command = message.substring(0, 6); + + var runnable = (Runnable) () -> { + try { + if ("!marek".equals(command) || "!Marek".equals(command)) { + handle(post); + } else if ("!MAREK".equals(command) && !ANNOYED_RESPONSES.isEmpty()) { + handleAnnoyed(); + } else if ("!marek".equalsIgnoreCase(command)) { + var uppercase = command.chars().filter(chr -> 'A' <= chr && chr <= 'Z').count(); + ScopedValue.runWhere(ANNOYANCE, uppercase / 5d, () -> handle(post)); + } + } catch (PizzaBotException ex) { + post(ex.getMessage()); + } + }; + + if (post.userId() != null && post.userName() != null) { + ScopedValue.runWhere(USER, new User(post.userId(), post.userName()), runnable); + } else { + runnable.run(); + } + } + + private void handle(@NotNull Post post) { + if (!isPrimaryChannel(post.channel())) { + handleNonPrimaryChannel(); + } else { + try { + dispatch(ArgumentParser.parse(post.message().substring(6))); + } catch (SQLException ex) { + log.error("Fehler beim Verarbeiten von {}.", post, ex); + } + } + } + + private void handleAnnoyed() { + assert !ANNOYED_RESPONSES.isEmpty(); + + var idx = (int) (Math.random() * ANNOYED_RESPONSES.size()); + post(ANNOYED_RESPONSES.get(idx)); + } + + private void handleNonPrimaryChannel() { + assert primaryChannels != null; + + if (primaryChannels.size() == 1) { + post(STR."Bitte benutze den Channel \{primaryChannels.iterator().next()}."); + } else { + var channels = new StringBuilder(); + var it = primaryChannels.iterator(); + while (it.hasNext()) { + var channel = it.next(); + if (!it.hasNext()) { + channels.append(" oder "); + } else if (!channels.isEmpty()) { + channels.append(", "); + } + channels.append(channel); + } + post(STR."Bitte benutze einen der Channel \{channels.toString()}."); + } + } + + private void dispatch(@NotNull List<@NotNull String> args) throws SQLException { + if (args.isEmpty()) { + helpUser(); + return; + } + + var argc = args.size(); + var command = args.getFirst(); + switch (command) { + case "--user", "-u" -> runAs(args); + case "help" -> { + if (argc > 1 && "--all".equals(args.get(1))) { + helpAdmin(); + } else { + helpUser(); + } + } + case "info" -> info(); + case "order" -> { + if (argc == 1) throw new PizzaBotException("too few arguments"); + switch (args.get(1)) { + case "revoke" -> { + if (argc == 2) orderRevoke(); + else if (argc != 3) throw new PizzaBotException("too many arguments"); + else switch (args.get(2)) { + case "--all" -> orderRevokeAll(); + case String s when isLong(s) -> orderRevoke(toLong(s)); + case String name -> orderRevoke(name); + } + } + case "list" -> { + if (argc == 2) orderList(); +// else if (argc == 3 && "--all".equals(args.get(2))) orderListAll(); + else throw new PizzaBotException("invalid command"); + } + case "add" -> { + if (argc < 4) throw new PizzaBotException("too few arguments"); + else if (argc > 4) throw new PizzaBotException("too many arguments"); + orderAdd(args.get(2), args.get(3)); + } + case String name when argc == 2 -> order(name, null); + case String name -> order(name, String.join(" ", args.subList(2, args.size()))); + } + } + case "summary" -> { + if (argc == 1) summary(); + else if (argc == 2 && "--all".equals(args.get(1))) summaryAll(); + else if (argc == 2) summary(args.get(1)); + else throw new PizzaBotException("invalid command"); + } + case "check" -> { + if (argc == 1) check(); + else if (argc == 2 && "--all".equals(args.get(1))) checkAll(); + else if (argc == 2) check(args.get(1)); + else throw new PizzaBotException("invalid command"); + } + case "pay" -> { + if (argc == 1) throw new PizzaBotException("missing argument"); + else if (argc == 2) pay(toDouble(args.get(1))); + else if (argc == 3) pay(args.get(1), toDouble(args.get(2))); + else throw new PizzaBotException("too many arguments"); + } + case "payment" -> { + if (argc == 1) throw new PizzaBotException("too few arguments"); + switch (args.get(1)) { + case "confirm" -> { + if (argc == 2) throw new PizzaBotException("too few arguments"); + else if (argc == 3 && "--all".equals(args.get(2))) throw new PizzaBotException("missing arguments"); + else if (argc == 3) paymentConfirm(toLong(args.get(2)), null); + else if (argc == 4 && "--all".equals(args.get(2))) paymentConfirmAll(args.get(3)); + else if (argc == 4) paymentConfirm(toLong(args.get(2)), args.get(3)); + else throw new PizzaBotException("invalid command"); + } + case "void" -> { + if (argc == 2) throw new PizzaBotException("too few arguments"); + else if (argc == 3) paymentVoid(toLong(args.get(2)), null); + else if (argc == 4) paymentVoid(toLong(args.get(2)), args.get(3)); + else throw new PizzaBotException("invalid command"); + } + default -> throw new PizzaBotException("invalid command"); + } + } + default -> throw new PizzaBotException("Unbekannter Befehl."); + } + } + + private @NotNull User getUser() { + if (USER.isBound()) { + return USER.get(); + } else { + throw new PizzaBotException("Diese Aktion kann nur mit öffentlicher ID ausgeführt werden."); + } + } + + // + private void runAs(@NotNull List args) throws SQLException { + // TODO + if (true) throw new PizzaBotException("nicht implementiert"); + + if (args.size() < 2) throw new PizzaBotException("missing argument"); + + var username = args.get(1); +// var user = service.getUserByEventAndName(, username); +// +// ScopedValue.runWhere(USER, user, () -> { +// try { +// dispatch(args.subList(2, args.size())); +// } catch (SQLException ex) { +// throw Lombok.sneakyThrow(ex); +// } +// }); +// +// if (false) throw new SQLException(); + } + + private void orderAdd(@NotNull String targetOrder, @NotNull String newUserName) throws SQLException { + var order = service.getCurrentOrder().checkAdminAccess(getUser()); + var newUser = service.getUserByName(newUserName); + + OrderItem item; + Pizza pizza; + List users; + try { + var targetId = Long.parseLong(targetOrder); + var detail = service.getItemById(targetId) + .orElseThrow(() -> new PizzaBotException(STR."Die Bestellung mit Nummer \{targetId} existiert nicht.")); + item = detail.item(); + pizza = detail.pizza(); + users = detail.users(); + } catch (NumberFormatException _) { + var user = service.getUserByEventAndName(order.eventId(), targetOrder); + try { + item = service.getItemByUser(order.id(), user.id()) + .orElseThrow(() -> new PizzaBotException(STR."\{user.username()} hat keine Bestellung aufgegeben.")); + pizza = service.getPizzaById(item.pizzaId()).orElseThrow(); + users = service.getUsersByItem(item); + } catch (NonUniqueResultException ex) { + throw new PizzaBotException(STR."\{user.username()} hat mehr als eine Bestellung aufgegeben. Bitte gibt die Positionsnummer an.", ex); + } + } + + var payed = users.stream().filter(UserWithPayment::payed).map(UserWithPayment::username).toList(); + if (!payed.isEmpty()) { + throw new PizzaBotException(STR."\{join(payed)} \{payed.size() == 1 ? "hat" : "haben"} schon bezahlt."); + } + + if (users.stream().anyMatch(u -> u.id() == newUser.id())) { + throw new PizzaBotException(STR."\{newUser.username()} nimmt bereits an dieser Bestellung teil."); + } + + service.add(item, newUser, false); + post(STR."\{newUser.username()} wurde zur Bestellung \{item.id()} (\{pizza.name()}) von \{join(users, UserWithPayment::username)} hinzugefügt."); + } + + /** + * Zeigt eine Übersicht alle Bestellungen während der Veranstaltung. + */ + private void summaryAll() throws SQLException { + var order = service.getCurrentOrder().checkAdminAccess(getUser()); + summary0(order.eventName(), service.getItemDetailByEvent(order.eventId())); + } + + /** + * Zeigt eine Übersicht der Positionen der laufenden Bestellung. + */ + private void summary() throws SQLException { + var order = service.getCurrentOrder().checkAdminAccess(getUser()); + summary0(order.name(), service.getItemDetailByOrder(order.id())); + } + + /** + * Zeigt eine Übersicht der Positionen der Bestellung mit dem angegebenen Namen. + */ + private void summary(@NotNull String name) throws SQLException { + var order = service.getCurrentOrder().checkAdminAccess(getUser()); + var other = service.getOrderByEventAndName(order.eventId(), name); + summary0(other.name(), service.getItemDetailByOrder(other.id())); + } + + private void summary0(@NotNull String name, @NotNull Collection details) { + var items = details.stream().collect(Collectors.groupingBy( + detail -> Pair.of(detail.pizza(), detail.notes()), + () -> new TreeMap<>(Comparator.comparing(Pair::first)), + Collectors.collectingAndThen( + Collectors.groupingBy( + OrderItemDetail::id, + Collectors.mapping(OrderItemDetail::userName, Collectors.joining("+")) + ), + Map::values + ) + )); + var lines = new ArrayList(items.size() + 2); + lines.add("Übersicht " + name); + lines.add(""); + if (items.isEmpty()) { + lines.add("(keine Einträge)"); + } else { + items.forEach((item, users) -> { + var counts = users.stream().collect(Collectors.groupingBy( + Function.identity(), + Collectors.counting() + )); + var usersString = counts.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> entry.getValue() == 1 ? entry.getKey() : entry.getValue() + "x " + entry.getKey()) + .collect(Collectors.joining(", ")); + var pizza = item.first().name().toUpperCase(Locale.ROOT); + var notes = item.second() != null ? " " + item.second() : ""; + lines.add(STR."\{tabular(users.size())}x \{pizza}\{notes} (\{usersString})"); + }); + } + post(String.join("\n", lines)); + } + + /** + * Zeigt die zusammengefasste Rechnung für alle Bestellungen während der Veranstaltung. + */ + private void checkAll() throws SQLException { + var order = service.getCurrentOrder().checkAdminAccess(getUser()); + var details = service.getItemDetailByEvent(order.eventId()); + + var tips = details.stream().collect(Collectors.groupingBy( + OrderItemDetail::order, + Collectors.collectingAndThen(Collectors.toList(), list -> calculateTip(list.getFirst().orderPayment(), list)) + )); + + var items = details.stream().collect(Collectors.groupingBy( + OrderItemDetail::user, + Collectors.reducing(Receipt.empty(), item -> item.receipt(tips.get(item.order())), Receipt::sum) + )); + check0(order.eventName(), tips.values().stream().anyMatch(tip -> tip > 0), items); + } + + /** + * Zeigt die Rechnung für der laufenden Bestellung. + */ + private void check() throws SQLException { + var order = service.getCurrentOrder().checkAdminAccess(getUser()); + check0(order.order()); + } + + /** + * Zeigt die Rechnung für die Bestellung mit dem angegebenen Namen. + */ + private void check(@NotNull String name) throws SQLException { + var order = service.getCurrentOrder().checkAdminAccess(getUser()); + var other = service.getOrderByEventAndName(order.eventId(), name); + check0(other); + } + + private static double calculateTip(@Nullable Double payment, List<@NotNull OrderItemDetail> details) { + var total = details.stream().mapToDouble(OrderItemDetail::userPrice).sum(); + return payment == null ? 0 : payment / total; + } + + private void check0(@NotNull Order order) throws SQLException { + var details = service.getItemDetailByOrder(order.id()); + + var tip = calculateTip(order.payment(), details); + var items = details.stream().collect(Collectors.groupingBy( + OrderItemDetail::user, + Collectors.reducing(Receipt.empty(), item -> item.receipt(tip), Receipt::sum) + )); + check0(order.name(), order.payment() != null, items); + } + + private void check0(@NotNull String name, boolean tipped, @NotNull Map items) { + var lines = new ArrayList(); + lines.add("Rechnung für " + name + (tipped ? " (mit Trinkgeld)" : "")); + lines.add("alle Angaben ohne Gewähr"); + lines.add(""); + if (items.isEmpty()) { + lines.add("(keine Einträge)"); + } else { + var toString = (BiConsumer) (user, receipt) -> { + if (receipt.payed()) { + lines.add(strikethrough(user + ": " + receipt.totalAsString())); + } else if (receipt.unpayed()) { + lines.add(user + ": " + receipt.totalAsString()); + } else { + lines.add(user + ": " + strikethrough(receipt.totalAsString()) + " " + receipt.pendingAsString()); + } + }; + items.forEach((user, receipt) -> toString.accept(user.username(), receipt)); + lines.add(""); + toString.accept("Gesamt", items.values().stream().reduce(Receipt::sum).orElseThrow()); + } + post(String.join("\n", lines)); + } + + private void pay(double amount) throws SQLException { + var order = service.getCurrentOrder().checkAdminAccess(getUser()); + service.save(order.order().withPayment(amount)); + post(FMT."Zahlung von %.2f\{amount} für \{order.name()} wurde gespeichert"); + } + + private void pay(@NotNull String name, double amount) throws SQLException { + var order = service.getCurrentOrder().checkAdminAccess(getUser()); + var other = service.getOrderByEventAndName(order.eventId(), name); + service.save(other.withPayment(amount)); + post(FMT."Zahlung von %.2f\{amount} für \{other.name()} wurde gespeichert"); + } + +// /** +// * Beginnt eine neue Bestellung. +// * @param restaurant die Restaurant-ID +// */ +// private void orderStart(@NotNull String restaurant, @NotNull String deadline) { +// } +// +// /** +// * Beginnt eine neue Bestellung. +// * @param restaurantId die Restaurant-ID +// */ +// private void orderStart(long restaurantId, @NotNull String deadline) { +// var restaurant = restaurantRepository.getById(restaurantId) +// .orElseThrow(() -> new BreakException(STR."Das Restaurant mit der ID \{restaurantId} existiert nicht.")); +// var order = new Order(-1, , restaurantId, , , null); +// orderRepository.save(order); +// } +// +// /** +// * Beendet eine Bestellung. +// * @param payment der gezahlte Betrag inkl. Trinkgeld +// */ +// private void orderFinish(double payment) { +// var order = service.getCurrentOrderAsAdmin(); +// orderRepository.save(order.withPayment(payment)); +// check(order.id()); +// } + + /** + * Bestätigt den Zahlungseingang für alle ausstehenden Zahlungen der laufenden Veranstaltung eines Benutzers. + */ + private void paymentConfirmAll(@NotNull String name) throws SQLException { + var order = service.getCurrentOrder().checkAdminAccess(getUser()); + var user = service.getUserByEventAndName(order.eventId(), name); + var count = service.paymentByEventAndUser(order.eventId(), user.id(), true); + post(STR."\{count} ausstehenden Zahlungen für \{user.username()} wurden bestätigt."); + } + + /** + * Bestätigt den Zahlungseingang für eine Bestellung. + */ + private void paymentConfirm(long itemId, @Nullable String username) throws SQLException { + payment0(itemId, username, true, (pizza, user) -> STR."Zahlungseingang für \{pizza} (\{user}) wurde bestätigt."); + } + + /** + * Widerruft die Bestätigung eines Zahlungseingangs. + */ + private void paymentVoid(long itemId, @Nullable String username) throws SQLException { + payment0(itemId, username, false, (pizza, user) -> STR."Zahlungseingang für \{pizza} (\{user}) wurde bestätigt."); + } + + private void payment0(long itemId, @Nullable String username, boolean payed, @NotNull BinaryOperator message) throws SQLException { + var _ = service.getCurrentOrder().checkAdminAccess(getUser()); + var summary = service.getItemById(itemId) + .orElseThrow(() -> new PizzaBotException(STR."Die Bestellung mit Nummer \{itemId} existiert nicht.")); + if (username != null) { + var users = summary.users().stream().filter(u -> u.username().startsWith(username)).toList(); + if (users.isEmpty()) { + throw new PizzaBotException("Der Benutzer existiert nicht oder hat nicht an der Bestellung teilgenommen."); + } else if (users.size() > 1) { + throw new PizzaBotException("Der Benutzer ist nicht eindeutig."); + } else { + var user = users.getFirst(); + service.paymentByItemAndUser(itemId, user.id(), true); + post(message.apply(summary.pizza().name(), user.username())); + } + } else { + service.paymentByItem(summary.item().id(), payed); + post(message.apply(summary.pizza().name(), join(" + ", summary.users(), UserWithPayment::username))); + } + } + // + + // + /** + * Zeigt Informationen zur aktuell laufenden Bestellung. + */ + private void info() throws SQLException { + var order = service.getCurrentOrder(); + var result = service.getRestaurantWithPizzas(order.restaurantId()); + var restaurant = result.first(); + var pizzas = result.second(); + + var lines = new ArrayList(); + lines.add(STR."Pizzabestellung bei \{restaurant.name()} in \{restaurant.city()}"); + lines.add(STR."Bestellannahmeschluss: \{DATE_FORMATTER.format(order.deadline())}"); + pizzas.stream().map(Pizza::toPrettyString).forEach(lines::add); + post(String.join("\n", lines)); + } + + /** + * Listet die eigenen Bestellungen auf. + */ + private void orderList() throws SQLException { + var user = getUser(); + var current = service.getCurrentOrder(); + + var itemsByOrder = service.getItemDetailByEventAndUser(current.eventId(), user.id()).stream().collect(Collectors.groupingBy( + OrderItemDetail::order, + TreeMap::new, + Collectors.toUnmodifiableList() + )); + + var lines = new ArrayList(); + lines.add("Bestellungen von " + user.username()); + if (itemsByOrder.isEmpty()) lines.add("(keine Einträge)"); + itemsByOrder.forEach((order, items) -> { + lines.add(""); + lines.add(order.name()); + items.stream().map(OrderItemDetail::toPrettyString).forEach(lines::add); + }); + post(String.join("\n", lines)); + } + + /** + * Storniert die einzige eigene Bestellung. + */ + private void orderRevoke() throws SQLException { + var order = service.getCurrentOrder().checkDeadline(); + var user = getUser(); + + try { + var item = service.getItemByUser(order.id(), user.id()); + if (item.isEmpty()) { + post("Du hast keine Bestellung aufgegeben."); + } else { + orderRevoke0(item.get()); + } + } catch (NonUniqueResultException ex) { + post("Du hast mehr als eine Bestellung aufgegeben. Bitte gib die Positionsnummer oder den Namen der Pizza an."); + } + } + + /** + * Storniert die angegebene, eigene Bestellung. + */ + private void orderRevoke(long itemId) throws SQLException { + var order = service.getCurrentOrder().checkDeadline(); + var item = service.getItemById(itemId); + if (item.isEmpty() || item.get().item().orderId() != order.id()) { + post(STR."Die Position mit Nummer \{itemId} existiert nicht."); + } else { + orderRevoke0(item.get().checkUserAccess(getUser()).item()); + } + } + + /** + * Storniert die eigene Bestellung der angegebene Pizza. + */ + private void orderRevoke(@NotNull String name) throws SQLException { + var order = service.getCurrentOrder().checkDeadline(); + var user = getUser(); + try { + var item = service.getItemByPizzaName(order.id(), user.id(), name); + if (item.isEmpty()) { + post("Du hast keine solche Pizza bestellt."); + } else { + orderRevoke0(item.get()); + } + } catch (NonUniqueResultException ex) { + post("Du hast mehr als eine solche Pizza bestellt. Bitte gib die Positionsnummer an."); + } + } + + private void orderRevoke0(@NotNull OrderItem item) throws SQLException { + var pizza = service.getPizzaById(item.pizzaId()).orElseThrow(); + service.delete(item); + post(STR."Die Position \{item.id()} (\{pizza.name()}) wurde storniert."); + } + + /** + * Storniert alle eigenen Bestellungen. + */ + private void orderRevokeAll() throws SQLException { + var order = service.getCurrentOrder().checkDeadline(); + var user = getUser(); + var items = service.getItemsByUser(order.id(), user.id()); + if (items.isEmpty()) { + post("Du hast keine Bestellung aufgegeben."); + } else { + for (var item : items) { + service.delete(item); + } + post("Alle deine Bestellungen wurden storniert."); + } + } + + /** + * Bestellt die Pizza mit dem angegebenen Namen. + */ + private void order(@NotNull String nameOrNummer, @Nullable String notes) throws SQLException { + var order = service.getCurrentOrder().checkDeadline(); + var pizza = service.getPizza(order.restaurantId(), nameOrNummer); + order0(order, pizza, notes); + } + + private void order0(@NotNull OrderDetail order, @NotNull Pizza pizza, @Nullable String notes) throws SQLException { + var id = service.order(order, getUser(), pizza, notes); + post(STR."Pizza \{pizza.name()} wurde zur Bestellung hinzugefügt (Positionsnummer \{id})."); + } + // + + private void helpUser() { + post(""" + Marek is back! + !marek info - Menü anzeigen + + Bestellung + !marek order (NUMMER | NAME) [NOTIZEN] - Pizza bestellen + !marek order revoke [NUMMER | NAME | --all] - Bestellung(en) stornieren + !marek order list - Bestellungen auflisten + """); + } + + private void helpAdmin() { + post(""" + !marek --user USER order (NUMMER | NAME) [NOTIZEN] - Pizza bestellen + !marek --user USER order revoke [NUMMER | NAME | --all] - Bestellung(en) stornieren + !marek --user USER order list - Bestellungen auflisten + + !marek summary [NAME | --all] - Bestellübersicht + !marek check [NAME | --all] - Rechnung anzeigen + !marek pay [NAME] AMOUNT - Rechnungsbetrag angeben + !marek order list --all - Alle Bestellungen auflisten + !marek order add (NUMMER | NAME) USER - Fügt USER zur Bestellung mit Nummer NUMMER / von NAME hinzu + + !marek payment confirm NUMMER [USER] - Zahlungseingang bestätigen + !marek payment confirm --all USER - Zahlungseingang bestätigen + !marek payment void NUMMER [USER] - Widerruf der Bestätigung des Zahlungseingangs + """); + } + + @Override + protected void post(@NotNull String message, @Nullable String name, @Nullable Boolean bottag, @Nullable Boolean publicId) { + var annoyance = ANNOYANCE.orElse(0d); + if (annoyance == 0) { + super.post(message, name, bottag, publicId); + } else if (annoyance == 1) { + super.post(message.toUpperCase(Locale.ROOT), name, bottag, publicId); + } else { + var infuriatedMessage = message.codePoints() + .map(chr -> Math.random() < annoyance ? Character.toUpperCase(chr) : chr) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + super.post(infuriatedMessage, name, bottag, publicId); + } + } + + private boolean isPrimaryChannel(@NotNull String channel) { + return primaryChannels == null || primaryChannels.contains(channel); + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/PizzaBotException.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/PizzaBotException.java new file mode 100644 index 0000000..2ebec32 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/PizzaBotException.java @@ -0,0 +1,7 @@ +package eu.jonahbauer.chat.bot.pizza; + +import lombok.experimental.StandardException; + +@StandardException +public class PizzaBotException extends RuntimeException { +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/PizzaService.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/PizzaService.java new file mode 100644 index 0000000..5134af1 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/PizzaService.java @@ -0,0 +1,298 @@ +package eu.jonahbauer.chat.bot.pizza; + +import eu.jonahbauer.chat.bot.database.Database; +import eu.jonahbauer.chat.bot.database.NonUniqueResultException; +import eu.jonahbauer.chat.bot.pizza.model.*; +import eu.jonahbauer.chat.bot.pizza.model.table.*; +import eu.jonahbauer.chat.bot.pizza.model.view.OrderDetail; +import eu.jonahbauer.chat.bot.pizza.model.view.OrderItemDetail; +import eu.jonahbauer.chat.bot.pizza.util.Pair; +import org.intellij.lang.annotations.Language; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public class PizzaService { + private final @NotNull Database database; + + public PizzaService(@NotNull Database database) { + this.database = Objects.requireNonNull(database); + } + + public @NotNull OrderDetail getCurrentOrder() throws SQLException { + @Language("SQL") + var query = "SELECT * FROM order_detail WHERE active = TRUE ORDER BY deadline DESC LIMIT 1"; + return database.executeUniqueQuery(OrderDetail.class, query) + .orElseThrow(() -> new PizzaBotException("Aktuell läuft keine Pizzabestellung.")); + } + + public @NotNull Pair<@NotNull Restaurant, @NotNull List<@NotNull Pizza>> getRestaurantWithPizzas(long restaurantId) throws SQLException { + return database.transaction(() -> { + var restaurant = database.findById(Restaurant.class, restaurantId).orElseThrow(); + var pizzas = database.findAllWhere(Pizza.class, Pizza.Fields.restaurantId, restaurantId); + return Pair.of(restaurant, pizzas); + }); + } + + // Payment + + public void save(@NotNull Order order) throws SQLException { + database.update(order); + } + + public @NotNull User getUserByName(@NotNull String name) throws SQLException { + try { + @Language("SQL") + var query = """ + SELECT "user".* FROM "user" + WHERE "user"."username" LIKE ? + """; + return database.executeUniqueQuery(User.class, query, stmt -> stmt.setString(1, name + "%")) + .orElseThrow(() -> new PizzaBotException("Der Benutzer existiert nicht.")); + } catch (NonUniqueResultException _) { + throw new PizzaBotException("Der Benutzer ist nicht eindeutig."); + } + } + + public @NotNull User getUserByEventAndName(long eventId, @NotNull String name) throws SQLException { + try { + @Language("SQL") + var query = """ + SELECT "user".* FROM "user" + WHERE "user"."username" LIKE ? + AND EXISTS( + SELECT * FROM "order_item_to_user" + JOIN "order_item" ON "order_item_to_user"."order_item_id" = "order_item"."id" + JOIN "order" ON "order_item"."order_id" = "order"."id" + WHERE "order_item_to_user"."user_id" = "user"."id" + AND "order"."event_id" = ? + ) + """; + return database.executeUniqueQuery(User.class, query, stmt -> { + stmt.setString(1, name + "%"); + stmt.setLong(2, eventId); + }).orElseThrow(() -> new PizzaBotException("Der Benutzer existiert nicht oder hat an keiner Pizzabestellung teilgenommen.")); + } catch (NonUniqueResultException _) { + throw new PizzaBotException("Der Benutzer ist nicht eindeutig."); + } + } + + public void paymentByItem(long itemId, boolean payed) throws SQLException { + database.executeUpdate("UPDATE order_item_to_user SET payed = ? WHERE order_item_id = ?", stmt -> { + stmt.setBoolean(1, payed); + stmt.setLong(2, itemId); + }); + } + + public int paymentByEventAndUser(long eventId, long userId, boolean payed) throws SQLException { + @Language("SQL") + var query = """ + UPDATE "order_item_to_user" + SET "payed" = ? + WHERE "order_item_to_user"."user_id" = ? + AND EXISTS( + SELECT * + FROM "order_item" + JOIN "order" ON "order_item"."order_id" = "order"."id" + WHERE "order"."event_id" = ? AND "order_item"."id" = "order_item_to_user"."order_item_id" + ) + """; + return database.executeUpdate(query, stmt -> { + stmt.setBoolean(1, payed); + stmt.setLong(2, userId); + stmt.setLong(3, eventId); + }); + } + + public void paymentByItemAndUser(long itemId, long userId, boolean payed) throws SQLException { + database.executeUpdate("UPDATE order_item_to_user SET payed = ? WHERE order_item_id = ? AND user_id = ?", stmt -> { + stmt.setBoolean(1, payed); + stmt.setLong(2, itemId); + stmt.setLong(3, userId); + }); + } + + // Order + + public @NotNull Optional getPizzaById(long id) throws SQLException { + return database.findById(Pizza.class, id); + } + + public @NotNull Pizza getPizza(long restaurantId, @NotNull String nameOrNummer) throws SQLException { + try { + var nummer = Integer.parseInt(nameOrNummer); + return getPizzaByNummer(restaurantId, nummer); + } catch (NumberFormatException _) { + return getPizzaByName(restaurantId, nameOrNummer); + } + } + + private @NotNull Pizza getPizzaByNummer(long restaurantId, int nummer) throws SQLException { + @Language("SQL") + var query = "SELECT * FROM pizza WHERE restaurant_id = ? AND number = ?"; + return database.executeUniqueQuery(Pizza.class, query, stmt -> { + stmt.setLong(1, restaurantId); + stmt.setInt(2, nummer); + }).orElseThrow(() -> new PizzaBotException("Diese Pizza kenne ich nicht.")); + } + + private @NotNull Pizza getPizzaByName(long restaurantId, @NotNull String name) throws SQLException { + try { + @Language("SQL") + var query = "SELECT * FROM pizza WHERE restaurant_id = ? AND name LIKE ?"; + return database.executeUniqueQuery(Pizza.class, query, stmt -> { + stmt.setLong(1, restaurantId); + stmt.setString(2, name + "%"); + }).orElseThrow(() -> new PizzaBotException("Diese Pizza kenne ich nicht.")); + } catch (NonUniqueResultException _) { + throw new PizzaBotException("Diese Pizza kenne ich nicht."); + } + } + + public long order(@NotNull OrderDetail order, @NotNull User user, @NotNull Pizza pizza, @Nullable String notes) throws SQLException { + return database.transaction(() -> { + try { + database.insert(user); + } catch (SQLException _) {} + + var item = new OrderItem(-1, order.id(), pizza.id(), notes); + var itemId = database.insert(item); + + var mapping = new OrderItemToUser(itemId, user.id(), false); + database.insert(mapping); + + return itemId; + }); + } + + public void add(@NotNull OrderItem item, @NotNull User user, boolean payed) throws SQLException { + database.insert(new OrderItemToUser(item.id(), user.id(), payed)); + } + + public @NotNull List<@NotNull UserWithPayment> getUsersByItem(@NotNull OrderItem item) throws SQLException { + @Language("SQL") + var query = """ + SELECT + "user"."id" AS "id", + "user"."username" AS "username", + "order_item_to_user"."order_item_id" AS "order_item_id", + "order_item_to_user"."payed" AS "payed" + FROM "user" + JOIN "order_item_to_user" ON "user"."id" = "order_item_to_user"."user_id" + WHERE "order_item_to_user"."order_item_id" = ? + """; + return database.executeQuery(UserWithPayment.class, query, stmt -> stmt.setLong(1, item.id())); + } + + // Revoke + + public @NotNull Optional getItemById(long itemId) throws SQLException { + return database.transaction(() -> { + var item = database.findById(OrderItem.class, itemId); + if (item.isEmpty()) return Optional.empty(); + + var pizza = database.findById(Pizza.class, item.get().pizzaId()).orElseThrow(); + var users = getUsersByItem(item.get()); + + return Optional.of(new OrderItemSummary(item.get(), pizza, users)); + }); + } + + public @NotNull Optional getItemByPizzaName(long orderId, long userId, @NotNull String name) throws SQLException { + @Language("SQL") + var query = """ + SELECT "order_item".* + FROM "order_item" + JOIN "pizza" ON "order_item"."pizza_id" = "pizza"."id" + JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id" + WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ? AND "pizza"."name" LIKE ? + """; + return database.executeUniqueQuery(OrderItem.class, query, stmt -> { + stmt.setLong(1, orderId); + stmt.setLong(2, userId); + stmt.setString(3, name + "%"); + }); + } + + public @NotNull Optional getItemByUser(long orderId, long userId) throws SQLException { + @Language("SQL") + var query = """ + SELECT "order_item".* + FROM "order_item" + JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id" + WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ? + """; + return database.executeUniqueQuery(OrderItem.class, query, stmt -> { + stmt.setLong(1, orderId); + stmt.setLong(2, userId); + }); + } + + public @NotNull List<@NotNull OrderItem> getItemsByUser(long orderId, long userId) throws SQLException { + @Language("SQL") + var query = """ + SELECT "order_item".* + FROM "order_item" + JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id" + WHERE "order_item"."order_id" = ? AND "order_item_to_user"."user_id" = ? + """; + return database.executeQuery(OrderItem.class, query, stmt -> { + stmt.setLong(1, orderId); + stmt.setLong(2, userId); + }); + } + + public boolean delete(@NotNull OrderItem item) throws SQLException { + return database.delete(OrderItem.class, item.id()); + } + + // Summary + + public @NotNull Order getOrderByEventAndName(long eventId, @NotNull String name) throws SQLException { + try { + @Language("SQL") + var query = """ + SELECT "order".* + FROM "order" + WHERE "order"."event_id" = ? AND "order"."name" LIKE ? + """; + + return database.executeUniqueQuery(Order.class, query, stmt -> { + stmt.setLong(1, eventId); + stmt.setString(2, name + "%"); + }).orElseThrow(() -> new PizzaBotException("Diese Bestellung kenne ich nicht.")); + } catch (NonUniqueResultException ex) { + throw new PizzaBotException("Die Bestellung ist nicht eindeutig."); + } + } + + // Order List + + public @NotNull List<@NotNull OrderItemDetail> getItemDetailByEvent(long eventId) throws SQLException { + return database.findAllWhere(OrderItemDetail.class, OrderItemDetail.Fields.eventId, eventId); + } + + public @NotNull List<@NotNull OrderItemDetail> getItemDetailByOrder(long orderId) throws SQLException { + return database.findAllWhere(OrderItemDetail.class, OrderItemDetail.Fields.orderId, orderId); + } + + public @NotNull List<@NotNull OrderItemDetail> getItemDetailByEventAndUser(long eventId, long userId) throws SQLException { + @Language("SQL") + var query = """ + SELECT * + FROM order_item_detail + WHERE event_id = ? AND EXISTS( + SELECT * FROM "order_item_to_user" + WHERE "user_id" = ? AND "order_item_id" = "order_item_detail"."id" + ) + """; + return database.executeQuery(OrderItemDetail.class, query, stmt -> { + stmt.setLong(1, eventId); + stmt.setLong(2, userId); + }); + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/OrderItemSummary.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/OrderItemSummary.java new file mode 100644 index 0000000..ed8db45 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/OrderItemSummary.java @@ -0,0 +1,18 @@ +package eu.jonahbauer.chat.bot.pizza.model; + +import eu.jonahbauer.chat.bot.pizza.PizzaBotException; +import eu.jonahbauer.chat.bot.pizza.model.table.OrderItem; +import eu.jonahbauer.chat.bot.pizza.model.table.Pizza; +import eu.jonahbauer.chat.bot.pizza.model.table.User; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public record OrderItemSummary(@NotNull OrderItem item, @NotNull Pizza pizza, @NotNull List users) { + public @NotNull OrderItemSummary checkUserAccess(@NotNull User user) { + if (users.stream().noneMatch(u -> u.id() == user.id())) { + throw new PizzaBotException("Du bist nicht berechtigt, diese Bestellung zu bearbeiten."); + } + return this; + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/OrderItemToUser.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/OrderItemToUser.java new file mode 100644 index 0000000..f95a5cf --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/OrderItemToUser.java @@ -0,0 +1,14 @@ +package eu.jonahbauer.chat.bot.pizza.model; + +import lombok.experimental.FieldNameConstants; + +@FieldNameConstants +public record OrderItemToUser(long orderItemId, long userId, long parts, boolean payed) { + public OrderItemToUser(long orderItemId, long userId, boolean payed) { + this(orderItemId, userId, 1, payed); + } + + public OrderItemToUser withPayed(boolean payed) { + return new OrderItemToUser(orderItemId, userId, parts, payed); + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/Receipt.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/Receipt.java new file mode 100644 index 0000000..f8602de --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/Receipt.java @@ -0,0 +1,40 @@ +package eu.jonahbauer.chat.bot.pizza.model; + +import org.jetbrains.annotations.NotNull; + +import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular; + +public record Receipt(double total, double tipped, double pending, double pendingTipped) { + public Receipt(double total, boolean payed, double tip) { + this(total, total * tip, payed ? 0 : total, payed ? 0 : total * tip); + } + + public boolean payed() { + return pending == 0; + } + + public boolean unpayed() { + return total == pending; + } + + public @NotNull String totalAsString() { + return tabular(total, 0) + " €" + (tipped != 0 ? " (" + tabular(tipped, 0) + " €)" : ""); + } + + public @NotNull String pendingAsString() { + return tabular(pending, 0) + " €" + (pendingTipped != 0 ? " (" + tabular(pendingTipped, 0) + " €)" : ""); + } + + public static @NotNull Receipt empty() { + return new Receipt(0, 0, 0, 0); + } + + public static @NotNull Receipt sum(@NotNull Receipt first, @NotNull Receipt second) { + return new Receipt( + first.total + second.total, + first.tipped + second.tipped, + first.pending + second.pending, + first.pendingTipped + second.pendingTipped + ); + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/UserWithPayment.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/UserWithPayment.java new file mode 100644 index 0000000..e577bd5 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/UserWithPayment.java @@ -0,0 +1,7 @@ +package eu.jonahbauer.chat.bot.pizza.model; + +import org.jetbrains.annotations.NotNull; + +public record UserWithPayment(long id, @NotNull String username, long orderItemId, boolean payed) { + +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Event.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Event.java new file mode 100644 index 0000000..250ad66 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Event.java @@ -0,0 +1,9 @@ +package eu.jonahbauer.chat.bot.pizza.model.table; + +import eu.jonahbauer.chat.bot.database.Entity; +import lombok.experimental.FieldNameConstants; +import org.jetbrains.annotations.NotNull; + +@FieldNameConstants +public record Event(long id, @NotNull String name, long adminId, boolean active) implements Entity { +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Order.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Order.java new file mode 100644 index 0000000..4d7cd40 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Order.java @@ -0,0 +1,33 @@ +package eu.jonahbauer.chat.bot.pizza.model.table; + +import eu.jonahbauer.chat.bot.database.Entity; +import lombok.experimental.FieldNameConstants; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.Objects; + +@FieldNameConstants +public record Order( + long id, + long eventId, + long restaurantId, + @NotNull String name, + @NotNull Instant deadline, + @Nullable Double payment +) implements Entity, Comparable { + + public Order { + Objects.requireNonNull(deadline); + } + + public @NotNull Order withPayment(@Nullable Double payment) { + return new Order(id, eventId, restaurantId, name, deadline, payment); + } + + @Override + public int compareTo(@NotNull Order o) { + return this.deadline.compareTo(o.deadline); + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/OrderItem.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/OrderItem.java new file mode 100644 index 0000000..293b12f --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/OrderItem.java @@ -0,0 +1,14 @@ +package eu.jonahbauer.chat.bot.pizza.model.table; + +import eu.jonahbauer.chat.bot.database.Entity; +import lombok.experimental.FieldNameConstants; +import org.jetbrains.annotations.Nullable; + +@FieldNameConstants +public record OrderItem( + long id, + long orderId, + long pizzaId, + @Nullable String notes +) implements Entity { +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Pizza.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Pizza.java new file mode 100644 index 0000000..b6f8788 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Pizza.java @@ -0,0 +1,33 @@ +package eu.jonahbauer.chat.bot.pizza.model.table; + +import eu.jonahbauer.chat.bot.database.Entity; +import lombok.experimental.FieldNameConstants; +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; + +import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular; + +@FieldNameConstants +public record Pizza( + long id, + long restaurantId, + int number, + @NotNull String name, + @NotNull String ingredients, + double price +) implements Entity, Comparable { + + public @NotNull String toPrettyString() { + var number = tabular(number()); + var price = tabular(price(), 5); + var name = name().toUpperCase(Locale.ROOT); + var ingredients = ingredients(); + return STR."\{number} | \{price} € | \{name} \{ingredients}"; + } + + @Override + public int compareTo(@NotNull Pizza o) { + return Integer.compare(number, o.number); + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Restaurant.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Restaurant.java new file mode 100644 index 0000000..74fd87b --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/Restaurant.java @@ -0,0 +1,20 @@ +package eu.jonahbauer.chat.bot.pizza.model.table; + +import eu.jonahbauer.chat.bot.database.Entity; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +public record Restaurant( + long id, + @NotNull String name, + @NotNull String city, + @Nullable String phone, + @Nullable String notes +) implements Entity { + public Restaurant { + Objects.requireNonNull(name); + Objects.requireNonNull(city); + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/User.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/User.java new file mode 100644 index 0000000..b986429 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/table/User.java @@ -0,0 +1,13 @@ +package eu.jonahbauer.chat.bot.pizza.model.table; + +import lombok.experimental.FieldNameConstants; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +@FieldNameConstants +public record User(long id, @NotNull String username) { + public User { + Objects.requireNonNull(username); + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/view/OrderDetail.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/view/OrderDetail.java new file mode 100644 index 0000000..e89d53e --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/view/OrderDetail.java @@ -0,0 +1,42 @@ +package eu.jonahbauer.chat.bot.pizza.model.view; + +import eu.jonahbauer.chat.bot.pizza.PizzaBotException; +import eu.jonahbauer.chat.bot.pizza.model.table.Order; +import eu.jonahbauer.chat.bot.pizza.model.table.User; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; + +public record OrderDetail( + // Order + long id, + @NotNull String name, + @NotNull Instant deadline, + @Nullable Double payment, + // Event + long eventId, + @NotNull String eventName, + long adminId, + // Restaurant + long restaurantId, + @NotNull String restaurantName +) { + public @NotNull Order order() { + return new Order(id, eventId, restaurantId, name, deadline, payment); + } + + public @NotNull OrderDetail checkDeadline() { + if (Instant.now().isAfter(deadline())) { + throw new PizzaBotException("Du kannst diese Bestellung nicht mehr bearbeiten, da der Bestellannahmeschluss schon vorbei ist."); + } + return this; + } + + public @NotNull OrderDetail checkAdminAccess(@NotNull User user) { + if (adminId() != user.id()) { + throw new PizzaBotException("Du bist nicht berechtigt, diese Aktion durchzuführen."); + } + return this; + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/view/OrderItemDetail.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/view/OrderItemDetail.java new file mode 100644 index 0000000..6e4b29b --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/model/view/OrderItemDetail.java @@ -0,0 +1,79 @@ +package eu.jonahbauer.chat.bot.pizza.model.view; + +import eu.jonahbauer.chat.bot.pizza.model.Receipt; +import eu.jonahbauer.chat.bot.pizza.model.table.Order; +import eu.jonahbauer.chat.bot.pizza.model.table.Pizza; +import eu.jonahbauer.chat.bot.pizza.model.table.User; +import lombok.experimental.FieldNameConstants; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.Locale; + +import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.strikethrough; +import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.tabular; + +@FieldNameConstants +public record OrderItemDetail( + // OrderItem + long id, + @Nullable String notes, + // Order + long orderId, + @NotNull String orderName, + @NotNull Instant orderDeadline, + @Nullable Double orderPayment, + // Event + long eventId, + // Restaurant + long restaurantId, + @NotNull String restaurantName, + // Pizza + long pizzaId, + int pizzaNumber, + @NotNull String pizzaName, + double pizzaPrice, + // User + long userId, + @NotNull String userName, + long userParts, + boolean userPayed, + long userCount +) { + public @NotNull Order order() { + return new Order(orderId, eventId, restaurantId, orderName, orderDeadline, orderPayment); + } + + public @NotNull Pizza pizza() { + return new Pizza(pizzaId, restaurantId, pizzaNumber, pizzaName, "N/A", pizzaPrice); + } + + public @NotNull User user() { + return new User(userId, userName); + } + + public @NotNull Receipt receipt(double tip) { + return new Receipt(userPrice(), userPayed(), tip); + } + + public double userPrice() { + return userParts() * pizzaPrice() / userCount(); + } + + public @NotNull String toPrettyString() { + String price; + if (userCount() == 1) { + price = tabular(pizzaPrice(), 0) + " €"; + } else { + price = tabular(pizzaPrice(), 0) + " € / " + tabular(userCount(), 0) + " = " + tabular(userPrice(), 0) + " €"; + } + if (userPayed()) price = strikethrough(price); + + if (notes() != null) { + return STR."\{id()} | \{pizzaName().toUpperCase(Locale.ROOT)} \{notes()} | \{price}"; + } else { + return STR."\{id()} | \{pizzaName().toUpperCase(Locale.ROOT)} | \{price}"; + } + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/util/ArgumentParser.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/util/ArgumentParser.java new file mode 100644 index 0000000..d1ab2f5 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/util/ArgumentParser.java @@ -0,0 +1,90 @@ +package eu.jonahbauer.chat.bot.pizza.util; + +import eu.jonahbauer.chat.bot.pizza.PizzaBotException; +import lombok.experimental.UtilityClass; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +@UtilityClass +public class ArgumentParser { + public static @NotNull List<@NotNull String> parse(@NotNull String string) { + var list = new ArrayList(); + var quote = (char) 0; + var escaped = false; + + var current = new StringBuilder(); + var it = string.chars().iterator(); + while (it.hasNext()) { + var chr = (char) it.nextInt(); + if (escaped) { + if (chr != '\n') current.append(chr); + } else if (chr == '\\' && quote != '\'') { + escaped = true; + } else if (quote == 0 && chr == '"') { + quote = '"'; + } else if (quote == 0 && chr == '\'') { + quote = '\''; + } else if (quote == chr) { + quote = 0; + } else if (chr == ' ' || chr == '\t') { + if (!current.isEmpty()) { + list.add(current.toString()); + current.setLength(0); + } + } else if (chr == '\n') { + break; + } else { + current.append(chr); + } + } + + if (escaped) { + throw new IllegalArgumentException("incomplete escape sequence"); + } else if (quote != 0) { + throw new IllegalArgumentException("incomplete quote"); + } + + if (!current.isEmpty()) { + list.add(current.toString()); + } + + return Collections.unmodifiableList(list); + } + + public static boolean isLong(@NotNull String string) { + try { + Long.parseLong(string); + return true; + } catch (NumberFormatException _) { + return false; + } + } + + public static long toLong(@NotNull String string) { + try { + return Long.parseLong(string); + } catch (NumberFormatException ex) { + throw new PizzaBotException(string + " ist keine gültige Zahl."); + } + } + + public static boolean isDouble(@NotNull String string) { + try { + Double.parseDouble(string); + return true; + } catch (NumberFormatException _) { + return false; + } + } + + public static double toDouble(@NotNull String string) { + try { + return Double.parseDouble(string); + } catch (NumberFormatException ex) { + throw new PizzaBotException(string + " ist keine gültige Zahl."); + } + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/util/FormatUtils.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/util/FormatUtils.java new file mode 100644 index 0000000..b8cb871 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/util/FormatUtils.java @@ -0,0 +1,81 @@ +package eu.jonahbauer.chat.bot.pizza.util; + +import lombok.experimental.UtilityClass; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.TestOnly; + +import java.util.List; +import java.util.Locale; +import java.util.function.Function; +import java.util.stream.Collectors; + +@UtilityClass +public class FormatUtils { + public static @NotNull String tabular(long number) { + return tabular(number, 2); + } + + public static @NotNull String tabular(long number, int width) { + return tabular(String.format(Locale.ROOT, "%" + (width == 0 ? "" : width) + "d", number)); + } + + public static @NotNull String tabular(double number) { + return tabular(number, 6); + } + + public static @NotNull String tabular(double number, int width) { + return tabular(String.format(Locale.ROOT, "%" + (width == 0 ? "" : width) + ".2f", number)); + } + + private static @NotNull String tabular(@NotNull String string) { + var out = new StringBuilder(); + for (int i = 0, length = string.length(); i < length; i++) { + var chr = string.charAt(i); + if (chr == ' ') { + out.append('\u2007'); // figure space + } else { + out.append('\u2060'); // word-joiner to prevent kerning + out.append(chr); + } + } + return out.toString(); + } + + @SuppressWarnings("UnnecessaryUnicodeEscape") + public static @NotNull String strikethrough(@NotNull String string) { + var out = new StringBuilder(); + for (int i = 0, length = string.length(); i < length; i++) { + out.append(string.charAt(i)).append('\u0336'); + } + return out.toString(); + } + + @TestOnly + @SuppressWarnings("UnnecessaryUnicodeEscape") + public static @NotNull String strip(@NotNull String string) { + var out = new StringBuilder(); + for (int i = 0, length = string.length(); i < length; i++) { + var chr = string.charAt(i); + if (chr != '\u0336' && chr != '\u0335' && chr != '\u2007' && chr != '\u2060') { + out.append(chr); + } + } + return out.toString(); + } + + public static @NotNull String join(@NotNull List<@NotNull String> strings) { + return switch (strings.size()) { + case 0 -> ""; + case 1 -> strings.getFirst(); + default -> String.join(", ", strings.subList(0, strings.size() - 1)) + " und " + strings.getLast(); + }; + } + + public static @NotNull String join(@NotNull List<@NotNull T> items, @NotNull Function toString) { + return join(items.stream().map(toString).toList()); + } + + public static @NotNull String join(@NotNull String separator, @NotNull List<@NotNull T> items, @NotNull Function toString) { + return items.stream().map(toString).collect(Collectors.joining(separator)); + } +} diff --git a/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/util/Pair.java b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/util/Pair.java new file mode 100644 index 0000000..857b4b8 --- /dev/null +++ b/bots/pizza-bot/src/main/java/eu/jonahbauer/chat/bot/pizza/util/Pair.java @@ -0,0 +1,9 @@ +package eu.jonahbauer.chat.bot.pizza.util; + +import org.jetbrains.annotations.NotNull; + +public record Pair(F first, S second) { + public static @NotNull Pair of(F first, S second) { + return new Pair<>(first, second); + } +} diff --git a/bots/pizza-bot/src/main/java/module-info.java b/bots/pizza-bot/src/main/java/module-info.java new file mode 100644 index 0000000..89cd6b1 --- /dev/null +++ b/bots/pizza-bot/src/main/java/module-info.java @@ -0,0 +1,16 @@ +import eu.jonahbauer.chat.bot.api.ChatBot; +import eu.jonahbauer.chat.bot.pizza.PizzaBot; + +module eu.jonahbauer.chat.bot.pizza { + exports eu.jonahbauer.chat.bot.pizza.model.table to eu.jonahbauer.chat.bot.database; + exports eu.jonahbauer.chat.bot.pizza.model.view to eu.jonahbauer.chat.bot.database; + exports eu.jonahbauer.chat.bot.pizza.model to eu.jonahbauer.chat.bot.database; + + requires eu.jonahbauer.chat.bot.api; + requires eu.jonahbauer.chat.bot.database; + + requires org.slf4j; + requires static lombok; + + provides ChatBot with PizzaBot; +} \ No newline at end of file diff --git a/bots/pizza-bot/src/main/resources/eu/jonahbauer/chat/bot/pizza/init.sql b/bots/pizza-bot/src/main/resources/eu/jonahbauer/chat/bot/pizza/init.sql new file mode 100644 index 0000000..37bc659 --- /dev/null +++ b/bots/pizza-bot/src/main/resources/eu/jonahbauer/chat/bot/pizza/init.sql @@ -0,0 +1,115 @@ +CREATE TABLE "user" +( + "id" INTEGER PRIMARY KEY, + "username" VARCHAR(255) NOT NULL, + CONSTRAINT "uq_username" UNIQUE ("username") +); + +CREATE TABLE "restaurant" +( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "name" VARCHAR(255) NOT NULL, + "city" VARCHAR(255) NOT NULL, + "phone" VARCHAR(255) NULL DEFAULT NULL, + "notes" VARCHAR(255) NULL DEFAULT NULL +); + +CREATE TABLE "pizza" +( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "restaurant_id" INTEGER NOT NULL, + "number" INTEGER NOT NULL, + "name" VARCHAR(255) NOT NULL, + "ingredients" VARCHAR(255) NOT NULL, + "price" VARCHAR(255) NOT NULL, + CONSTRAINT "fk_pizza_restaurant" FOREIGN KEY ("restaurant_id") REFERENCES "restaurant" ("id") ON DELETE CASCADE, + CONSTRAINT "uq_pizza_number" UNIQUE ("restaurant_id", "number") +); + +CREATE TABLE "event" +( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "name" VARCHAR(255) NOT NULL, + "admin_id" INTEGER NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT FALSE, + CONSTRAINT "fk_event_admin" FOREIGN KEY ("admin_id") REFERENCES "user" ("id") +); + +CREATE UNIQUE INDEX "uq_event_active" ON "event" ("active") + WHERE "active" = TRUE; + +CREATE TABLE "order" +( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "event_id" INTEGER NOT NULL, + "restaurant_id" INTEGER NOT NULL, + "name" VARCHAR(255) NOT NULL, + "deadline" TIMESTAMP NOT NULL, + "payment" DOUBLE NULL, + CONSTRAINT "fk_order_event" FOREIGN KEY ("event_id") REFERENCES "event" ("id"), + CONSTRAINT "fk_order_restaurant" FOREIGN KEY ("restaurant_id") REFERENCES "restaurant" ("id") +); + +CREATE TABLE "order_item" +( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "order_id" INTEGER NOT NULL, + "pizza_id" INTEGER NOT NULL, + "notes" VARCHAR(255), + CONSTRAINT "fk_order_item_order" FOREIGN KEY ("order_id") REFERENCES "order" ("id") ON DELETE CASCADE, + CONSTRAINT "fk_order_item_pizza" FOREIGN KEY ("pizza_id") REFERENCES "pizza" ("id") +); + +CREATE TABLE "order_item_to_user" +( + "order_item_id" INTEGER NOT NULL, + "user_id" INTEGER NOT NULL, + "parts" INTEGER NOT NULL DEFAULT 1, + "payed" BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY ("order_item_id", "user_id"), + CONSTRAINT "fk_order_item_to_user_order_item" FOREIGN KEY ("order_item_id") REFERENCES "order_item" ("id") ON DELETE CASCADE, + CONSTRAINT "fk_order_item_to_user_user_id" FOREIGN KEY ("user_id") REFERENCES "user" ("id") +); + +CREATE VIEW "order_item_detail" +AS +SELECT "order_item"."id", + "order_item"."notes", + "order"."id" AS "order_id", + "order"."name" AS "order_name", + "order"."deadline" AS "order_deadline", + "order"."payment" AS "order_payment", + "order"."event_id" AS "event_id", + "restaurant"."id" AS "restaurant_id", + "restaurant"."name" AS "restaurant_name", + "pizza"."id" AS "pizza_id", + "pizza"."number" AS "pizza_number", + "pizza"."name" AS "pizza_name", + "pizza"."price" AS "pizza_price", + "order_item_to_user"."user_id" AS "user_id", + "order_item_to_user"."payed" AS "user_payed", + "order_item_to_user"."parts" AS "user_parts", + "user"."username" AS "user_name", + (SELECT SUM("parts") FROM "order_item_to_user" WHERE "order_item_id" = "order_item"."id") AS "user_count" +FROM "order" + JOIN "restaurant" ON "order"."restaurant_id" = "restaurant"."id" + JOIN "order_item" ON "order"."id" = "order_item"."order_id" + JOIN "pizza" ON "order_item"."pizza_id" = "pizza"."id" + JOIN "order_item_to_user" ON "order_item"."id" = "order_item_to_user"."order_item_id" + JOIN "user" ON "order_item_to_user"."user_id" = "user"."id"; + +CREATE VIEW "order_detail" +AS +SELECT "order"."id", + "order"."name", + "order"."deadline", + "order"."payment", + "event"."id" AS "event_id", + "event"."name" AS "event_name", + "event"."admin_id", + "event"."active", + "restaurant"."id" AS "restaurant_id", + "restaurant"."name" AS "restaurant_name" +FROM "event" + JOIN "order" ON "event"."id" = "order"."event_id" + JOIN "restaurant" ON "order"."restaurant_id" = "restaurant"."id"; \ No newline at end of file diff --git a/bots/pizza-bot/src/main/resources/eu/jonahbauer/chat/bot/pizza/restaurants/sonthofen_2023.sql b/bots/pizza-bot/src/main/resources/eu/jonahbauer/chat/bot/pizza/restaurants/sonthofen_2023.sql new file mode 100644 index 0000000..f4bfb8d --- /dev/null +++ b/bots/pizza-bot/src/main/resources/eu/jonahbauer/chat/bot/pizza/restaurants/sonthofen_2023.sql @@ -0,0 +1,38 @@ +INSERT INTO "restaurant"("name", "city", "phone", "notes") +VALUES ('Hell''s Pizza', 'Sonthofen', '08321 7881930', '2023'); + +WITH temp(id) AS (SELECT last_insert_rowid()) +INSERT INTO "pizza"("restaurant_id", "number", "name", "ingredients", "price") +SELECT * FROM temp CROSS JOIN ( +VALUES (10, 'Pizzabrot', 'mit Tomaten & Knoblauch', 6.50), + (11, 'Margherita', 'mit Tomaten, Mozzarella & Basilikum', 9.50), + (12, 'Salami', 'mit Tomaten, Mozzarella & Salami (gegen Aufpreis mit Rindersalami)', 10.00), + (13, 'Prosciutto', 'mit Tomaten, Mozzarella & Schinken', 10.00), + (14, 'Salsiccia', 'mit Tomaten, Mozzarella & scharfer Salami', 9.50), + (15, 'Funghi', 'mit Tomaten, Mozzarella & Champignons', 9.50), + (16, 'Romana', 'mit Tomaten, Mozzarella, Salami & Champignons', 10.50), + (17, 'Hawaii', 'mit Tomaten, Mozzarella, Schinken & Ananas', 10.50), + (18, 'Regina', 'mit Tomaten, Mozzarella, Schinken & Champignons', 10.50), + (19, 'Toscana', 'mit Tomaten, Mozzarella, Salami, Champignons & Peperoni', 11.00), + (20, 'Tonno', 'mit Tomaten, Mozzarella, Thunfisch, Zwiebeln & Oliven', 11.00), + (21, 'Quattro Formaggi', 'mit Tomaten & vier Käsesorten', 10.50), + (22, 'Calzone', 'mit Tomaten, Mozzarella, Schinken & Champignons', 10.50), + (24, 'Diavolo', 'mit Tomaten, Mozzarella, scharfer Salami, Peperoni & Oliven', 11.00), + (25, 'Rucoletta', 'mit Tomaten, Mozzarella, Rucola, Tomatenstücken & Parmesan', 11.00), + (26, 'Quattro Stagioni', 'mit Tomaten, Mozzarella, Schinken, Salami, Champignons, Artischocken, Sardellen & Oliven', 11.00), + (27, 'Vegana', 'mit Tomaten & verschiedenem Gemüse', 10.50), + (28, 'Siciliana', 'mit Tomaten, Mozzarella, Peperoni, Kapern, Sardellen, Oliven & Knoblauch', 10.00), + (29, 'Rustica', 'mit Tomaten, Mozzarella, Speck, Champignons, Gorgonzola & Knoblauch', 11.00), + (30, 'Caprese', 'mit Tomaten, Mozzarella, Tomatenscheiben & Basilikum', 11.00), + (31, 'Calabria', 'mit Tomaten, Mozzarella, scharfer Salami, Zwiebeln & original Schafskäse', 12.00), + (32, 'Verdura', 'mit Tomaten, Mozzarella, gegrilltem Gemüse & Knoblauch', 12.00), + (33, 'Mare', 'mit Tomaten, Meeresfrüchten & Knoblauch', 13.00), + (35, 'Mafiosa', 'mit Tomaten, Mozzarella, Speck, scharfer Salami, Champignons, Spinat & Peperoni', 12.00), + (36, 'Reale', 'mit Tomaten, Mozzarella, original Parmaschinken, Rucola & Parmesan', 13.90), + (38, 'Mediterranea', 'mit Tomatenscheiben, Mozzarella, Thunfisch, Kapern & Rucola', 12.00), + (39, 'Tricolore', 'mit Büffel-Mozzarella, Tomatenscheiben & Rucola-Pesto', 13.00), + (40, 'Alfio Spezial', 'mit Mozzarella, Tomatenstücken, Salsiccia, Zwiebeln, Parmesan, Oliven & Basilikum', 12.00), + (41, 'Allgäuer', 'mit Tomaten, Mozzarella, Speck, Bergkäse, Röstzwiebeln & Knoblauch', 11.50), + (42, 'Buon Gustaio', 'mit Tomaten, Mozzarella, Spinat, Gorgonzola & Knoblauch', 11.00), + (44, 'Mista', 'mit Tomaten, Mozzarella, Schinken, Salami, Pilzen & Thunfisch', 11.00) +); \ No newline at end of file diff --git a/bots/pizza-bot/src/main/resources/eu/jonahbauer/chat/bot/pizza/restaurants/sonthofen_2024.sql b/bots/pizza-bot/src/main/resources/eu/jonahbauer/chat/bot/pizza/restaurants/sonthofen_2024.sql new file mode 100644 index 0000000..562cc04 --- /dev/null +++ b/bots/pizza-bot/src/main/resources/eu/jonahbauer/chat/bot/pizza/restaurants/sonthofen_2024.sql @@ -0,0 +1,46 @@ +-- Hell's Pizza Sonthofen +-- Stand: Oktober 2023 +INSERT INTO "restaurant"("name", "city", "phone", "notes") +VALUES ('Hell''s Pizza', 'Sonthofen', '08321 7881930', '2024'); + +WITH temp(id) AS (SELECT last_insert_rowid()) +INSERT INTO "pizza"("restaurant_id", "number", "name", "ingredients", "price") +SELECT * FROM temp CROSS JOIN ( + VALUES (10, 'Pizzabrot', 'mit Tomaten & Knoblauch', 7.00), + (11, 'Margherita', 'mit Tomaten, Mozzarella & Basilikum', 10.00), + (12, 'Salami', 'mit Tomaten, Mozzarella & Salami (gegen Aufpreis mit Rindersalami)', 10.50), + (13, 'Prosciutto', 'mit Tomaten, Mozzarella & Schinken', 10.50), + (14, 'Salsiccia', 'mit Tomaten, Mozzarella & scharfer Salami', 10.50), + (15, 'Funghi', 'mit Tomaten, Mozzarella & Champignons', 10.00), + (16, 'Romana', 'mit Tomaten, Mozzarella, Salami & Champignons', 11.00), + (17, 'Hawaii', 'mit Tomaten, Mozzarella, Schinken & Ananas', 11.00), + (18, 'Regina', 'mit Tomaten, Mozzarella, Schinken & Champignons', 11.00), + (19, 'Toscana', 'mit Tomaten, Mozzarella, Salami, Champignons & Peperoni', 11.50), + (20, 'Tonno', 'mit Tomaten, Mozzarella, Thunfisch, Zwiebeln & Oliven', 11.50), + (21, 'Quattro Formaggi', 'mit Tomaten & vier Käsesorten', 11.00), + (22, 'Calzone', 'mit Tomaten, Mozzarella, Schinken & Champignons', 11.00), + (23, 'Capriccio', 'mit Tomaten, Mozzarella, Schinken, Champignons, Artischocken, Eier', 13.00), + (24, 'Diavolo', 'mit Tomaten, Mozzarella, scharfer Salami, Peperoni & Oliven', 11.50), + (25, 'Rucoletta', 'mit Tomaten, Mozzarella, Rucola, Tomatenstücken & Parmesan', 12.00), + (26, 'Quattro Stagioni', 'mit Tomaten, Mozzarella, Schinken, Salami, Champignons, Artischocken, Sardellen & Oliven', 12.90), + (27, 'Vegana', 'mit Tomaten & verschiedenem Gemüse', 11.00), + (28, 'Siciliana', 'mit Tomaten, Mozzarella, Peperoni, Kapern, Sardellen, Oliven & Knoblauch', 10.50), + (29, 'Rustica', 'mit Tomaten, Mozzarella, Speck, Champignons, Gorgonzola & Knoblauch', 11.50), + (30, 'Caprese', 'mit Tomaten, Mozzarella, Tomatenscheiben & Basilikum', 11.50), + (31, 'Calabria', 'mit Tomaten, Mozzarella, scharfer Salami, Zwiebeln & original Schafskäse', 12.00), + (32, 'Verdura', 'mit Tomaten, Mozzarella, gegrilltem Gemüse & Knoblauch', 12.00), + (33, 'Mare', 'mit Tomaten, Meeresfrüchten & Knoblauch', 13.50), + (34, 'Contadina', 'mit Büffelmozzarella, Parmaschinken Julienne, Champignons & Rucola', 13.90), + (35, 'Mafiosa', 'mit Tomaten, Mozzarella, Speck, scharfer Salami, Champignons, Spinat & Peperoni', 12.90), + (36, 'Reale', 'mit Tomaten, Mozzarella, original Parmaschinken, Rucola & Parmesan', 14.90), + (38, 'Mediterranea', 'mit Tomatenscheiben, Mozzarella, Thunfisch, Kapern & Rucola', 12.90), + (39, 'Tricolore', 'mit Büffel-Mozzarella, Tomatenscheiben & Rucola-Pesto', 13.90), + (40, 'Alfio Spezial', 'mit Mozzarella, Tomatenstücken, Salsiccia, Zwiebeln, Parmesan, Oliven & Basilikum', 12.90), + (41, 'Allgäuer', 'mit Tomaten, Mozzarella, Speck, Bergkäse, Röstzwiebeln & Knoblauch DAS ORIGINAL', 12.90), + (42, 'Buon Gustaio', 'mit Tomaten, Mozzarella, Spinat, Gorgonzola & Knoblauch', 11.90), + (44, 'Mista', 'mit Tomaten, Mozzarella, Schinken, Salami, Pilzen & Thunfisch', 11.90), + (48, 'Cudduruni', 'Tomatenstücken, Zwiebeln, Parmesan, Oliven, Basilikum, Chili-Olivenöl NEU', 13.90), + (49, 'Bufalina', 'mit Tomaten, Büffelmozzarella, frischer Basilikum und Olivenöl NEU', 14.90) +); + + diff --git a/bots/pizza-bot/src/test/java/eu/jonahbauer/chat/bot/pizza/PizzaBotTest.java b/bots/pizza-bot/src/test/java/eu/jonahbauer/chat/bot/pizza/PizzaBotTest.java new file mode 100644 index 0000000..0dd23b1 --- /dev/null +++ b/bots/pizza-bot/src/test/java/eu/jonahbauer/chat/bot/pizza/PizzaBotTest.java @@ -0,0 +1,629 @@ +package eu.jonahbauer.chat.bot.pizza; + +import eu.jonahbauer.chat.bot.api.Message.Post; +import eu.jonahbauer.chat.bot.pizza.model.*; +import eu.jonahbauer.chat.bot.pizza.model.table.*; +import eu.jonahbauer.chat.bot.test.MockChat; +import eu.jonahbauer.chat.bot.database.Database; +import eu.jonahbauer.chat.bot.test.ChatBotFactoryAccess; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +import static eu.jonahbauer.chat.bot.pizza.util.FormatUtils.*; +import static java.util.FormatProcessor.FMT; +import static org.junit.jupiter.api.Assertions.*; + +class PizzaBotTest { + @TempDir + static Path temp; + + Database database; + MockChat chat; + PizzaBot bot; + + User admin; + User user; + User user2; + + Event event; + Order order0; // alte bestellung (bestellannahmeschluss vorbei) + Order order1; // alte bestellung + Order order; // laufende bestellung + + Pizza salami; + Pizza margherita; + + @BeforeEach + void init() throws IOException, SQLException { + var file = Files.createTempFile(temp, "database", ".db"); + database = new Database("jdbc:sqlite:" + file.toAbsolutePath()); + run("init.sql"); + run("restaurants/sonthofen_2023.sql"); + + chat = new MockChat(); + bot = ChatBotFactoryAccess.create(() -> new PizzaBot(Collections.singleton("marek"), database)); + + admin = database.save(new User(0, "Admin")); + user = database.save(new User(1337, "User1")); + user2 = database.save(new User(42, "User2")); + + event = database.save(new Event(0, "Veranstaltung", admin.id(), true)); + order0 = database.save(new Order(0, event.id(), 1, "28.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() - 3_600_000), 90.0)); + order1 = database.save(new Order(0, event.id(), 1, "29.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() + 3_600_000), null)); + order = database.save(new Order(0, event.id(), 1, "30.03.2024", Instant.ofEpochMilli(System.currentTimeMillis() + 7_200_000), null)); + + salami = database.findWhere(Pizza.class, Pizza.Fields.name, "Salami").orElseThrow(); + margherita = database.findWhere(Pizza.class, Pizza.Fields.name, "Margherita").orElseThrow(); + } + + @AfterEach + void print() { + for (var message : chat.getMessages()) { + System.out.println("\n" + message.name() + "\n" + message.message()); + } + } + + private void run(@NotNull String script) throws SQLException, IOException { + try (var conn = database.getConnection()) { + var runner = new ScriptRunner(conn, false, true); + runner.setLogWriter(null); + try (var stream = Objects.requireNonNull(PizzaBot.class.getResourceAsStream(script))) { + try (var reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { + runner.runScript(reader); + } + } + } + } + + private void deadline() throws SQLException { + database.delete(order); + database.delete(order1); + order = order0; + } + + private void post(@NotNull User user, @NotNull String message) { + var post = new Post(0, user.username(), message, "marek", LocalDateTime.now(), 0L, user.id(), user.username(), "ffffff", 0); + bot.onMessage(chat, post); + } + + @Nested + class OrderPizza { + + @Test + void orderByPizzaName() throws SQLException { + post(user, "!marek order SALAMI"); + + var items = database.findAll(OrderItem.class); + assertEquals(1, items.size()); + + var item = items.getFirst(); + assertNull(item.notes()); + assertEquals(order.id(), item.orderId()); + assertEquals(salami.id(), item.pizzaId()); + + var mappings = database.findAll(OrderItemToUser.class); + assertEquals(List.of(new OrderItemToUser(item.id(), user.id(), false)), mappings); + } + + @Test + void orderByPizzaNameWithNotes() throws SQLException { + post(user, "!marek order SALAMI Hello World"); + + var items = database.findAll(OrderItem.class); + assertEquals(1, items.size()); + + var item = items.getFirst(); + assertEquals("Hello World", item.notes()); + assertEquals(order.id(), item.orderId()); + assertEquals(salami.id(), item.pizzaId()); + } + + @Test + void orderByPizzaNameFailsAfterDeadline() throws SQLException { + deadline(); + post(user, "!marek order SALAMI"); + + var items = database.findAll(OrderItem.class); + assertEquals(0, items.size()); + } + + @Test + void orderByPizzaNumber() throws SQLException { + post(user, "!marek order 13"); + + var items = database.findAll(OrderItem.class); + assertEquals(1, items.size()); + + var item = items.getFirst(); + assertNull(item.notes()); + assertEquals(order.id(), item.orderId()); + + var pizza = database.findById(Pizza.class, item.pizzaId()); + assertTrue(pizza.isPresent()); + assertEquals("Prosciutto", pizza.get().name()); + } + + @Test + void orderAddById() throws SQLException { + var item = database.save(new OrderItem(0, order.id(), salami.id(), null)); + database.insert(new OrderItemToUser(item.id(), user.id(), false)); + + post(admin, STR."!marek order add \{item.id()} \{user2.username()}"); + + assertEquals( + List.of( + new OrderItemToUser(item.id(), user.id(), false), + new OrderItemToUser(item.id(), user2.id(), false) + ), + database.findAll(OrderItemToUser.class) + ); + } + + @Test + void orderAddByName() throws SQLException { + var item = database.save(new OrderItem(0, order.id(), salami.id(), null)); + database.insert(new OrderItemToUser(item.id(), user.id(), false)); + + post(admin, STR."!marek order add \{user.username()} \{user2.username()}"); + + assertEquals( + List.of( + new OrderItemToUser(item.id(), user.id(), false), + new OrderItemToUser(item.id(), user2.id(), false) + ), + database.findAll(OrderItemToUser.class) + ); + } + + @Test + void orderAddByNameFailsWhenNotUnique() throws SQLException { + var item1 = database.save(new OrderItem(0, order.id(), salami.id(), null)); + database.insert(new OrderItemToUser(item1.id(), user.id(), false)); + var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null)); + database.insert(new OrderItemToUser(item2.id(), user.id(), false)); + + post(admin, STR."!marek order add \{user.username()} \{user2.username()}"); + + assertEquals( + List.of( + new OrderItemToUser(item1.id(), user.id(), false), + new OrderItemToUser(item2.id(), user.id(), false) + ), + database.findAll(OrderItemToUser.class) + ); + } + + @Test + void orderAddByIdFailsWhenAlreadyPayed() throws SQLException { + var item = database.save(new OrderItem(0, order.id(), salami.id(), null)); + database.insert(new OrderItemToUser(item.id(), user.id(), false)); + database.insert(new OrderItemToUser(item.id(), user2.id(), true)); + + post(admin, STR."!marek order add \{item.id()} \{admin.username()}"); + + assertEquals( + List.of( + new OrderItemToUser(item.id(), user.id(), false), + new OrderItemToUser(item.id(), user2.id(), true) + ), + database.findAll(OrderItemToUser.class) + ); + } + } + + @Nested + class Revoke { + + @Test + void orderRevoke() throws SQLException { + var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item1.id(), user.id(), false)); + var item2 = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item2.id(), user2.id(), false)); + + post(user, "!marek order revoke"); + + var items = database.findAll(OrderItem.class); + assertEquals(List.of(item2), items); + } + + @Test + void orderRevokeFailsAfterDeadline() throws SQLException { + deadline(); + + var item = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item.id(), user.id(), false)); + + post(user, "!marek order revoke"); + + var items = database.findAll(OrderItem.class); + assertEquals(List.of(item), items); + } + + @Test + void orderRevokeFailsWithMultipleItems() throws SQLException { + var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item1.id(), user.id(), false)); + var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null)); + database.insert(new OrderItemToUser(item2.id(), user.id(), false)); + + post(user, "!marek order revoke"); + + var items = database.findAll(OrderItem.class); + assertEquals(2, items.size()); + } + + @Test + void orderRevokeFailsWhenItemBelongsToOtherOrder() throws SQLException { + var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item.id(), user.id(), false)); + + post(user, "!marek order revoke"); + + var items = database.findAll(OrderItem.class); + assertEquals(List.of(item), items); + } + + @Test + void orderRevokeByNameWithMultipleItems() throws SQLException { + var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item1.id(), user.id(), false)); + var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null)); + database.insert(new OrderItemToUser(item2.id(), user.id(), false)); + + post(user, "!marek order revoke SALAMI"); + + var items = database.findAll(OrderItem.class); + assertEquals(List.of(item1), items); + } + + @Test + void orderRevokeByNameFailsWhenItemBelongsToOtherUser() throws SQLException { + var item = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item.id(), user.id(), false)); + + post(user2, "!marek order revoke MARGHERITA"); + + var items = database.findAll(OrderItem.class); + assertEquals(List.of(item), items); + } + + @Test + void orderRevokeByNameFailsWhenItemBelongsToOtherOrder() throws SQLException { + var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item.id(), user.id(), false)); + + post(user, "!marek order revoke MARGHERITA"); + + var items = database.findAll(OrderItem.class); + assertEquals(List.of(item), items); + } + + @Test + void orderRevokeById() throws SQLException { + var item = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item.id(), user.id(), false)); + + post(user, "!marek order revoke " + item.id()); + + var items = database.findAll(OrderItem.class); + assertEquals(List.of(), items); + } + + @Test + void orderRevokeByIdFailsWhenItemBelongsToOtherUser() throws SQLException { + var item = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item.id(), user.id(), false)); + + post(user2, "!marek order revoke " + item.id()); + + var items = database.findAll(OrderItem.class); + assertEquals(List.of(item), items); + } + + @Test + void orderRevokeByIdFailsWhenItemBelongsToOtherOrder() throws SQLException { + var item = database.save(new OrderItem(0, order1.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item.id(), user.id(), false)); + + post(user, "!marek order revoke " + item.id()); + + var items = database.findAll(OrderItem.class); + assertEquals(List.of(item), items); + } + + @Test + void orderRevokeAll() throws SQLException { + var item1 = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + database.insert(new OrderItemToUser(item1.id(), user.id(), false)); + var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null)); + database.insert(new OrderItemToUser(item2.id(), user.id(), false)); + var item3 = database.save(new OrderItem(0, order.id(), salami.id(), null)); + database.insert(new OrderItemToUser(item3.id(), user2.id(), false)); + + post(user, "!marek order revoke --all"); + + var items = database.findAll(OrderItem.class); + assertEquals(List.of(item3), items); + } + } + + @Nested + class Payment { + OrderItem item; + OrderItemToUser itemToUser; + OrderItemToUser itemToUser2; + + @BeforeEach + void init() throws SQLException { + item = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + itemToUser = database.save(new OrderItemToUser(item.id(), user.id(), false)); + itemToUser2 = database.save(new OrderItemToUser(item.id(), user2.id(), false)); + } + + @Test + void paymentConfirmById() throws SQLException { + post(admin, "!marek payment confirm " + item.id()); + + var mappings = database.findAll(OrderItemToUser.class); + assertEquals(List.of(itemToUser.withPayed(true), itemToUser2.withPayed(true)), mappings); + } + + @Test + void paymentConfirmByIdFailsWhenNotAdmin() throws SQLException { + post(user, "!marek payment confirm " + item.id()); + + var mappings = database.findAll(OrderItemToUser.class); + assertEquals(List.of(itemToUser, itemToUser2), mappings); + } + + @Test + void paymentConfirmByIdAndUser() throws SQLException { + post(admin, "!marek payment confirm " + item.id() + " " + user.username()); + + var mappings = database.findAll(OrderItemToUser.class); + assertEquals(List.of(itemToUser.withPayed(true), itemToUser2), mappings); + } + + @Test + void paymentConfirmByIdAndUserFailsWhenNotAdmin() throws SQLException { + post(user, "!marek payment confirm " + item.id() + " " + user.username()); + + var mappings = database.findAll(OrderItemToUser.class); + assertEquals(List.of(itemToUser, itemToUser2), mappings); + } + + @Test + void paymentConfirmByUser() throws SQLException { + var item2 = database.save(new OrderItem(0, order.id(), salami.id(), null)); + var item2ToUser = database.save(new OrderItemToUser(item2.id(), user.id(), false)); + var item2ToUser2 = database.save(new OrderItemToUser(item2.id(), user2.id(), false)); + + post(admin, "!marek payment confirm --all " + user.username()); + + var mappings = database.findAll(OrderItemToUser.class); + assertEquals( + List.of(itemToUser.withPayed(true), itemToUser2, item2ToUser.withPayed(true), item2ToUser2), + mappings + ); + } + } + + @Nested + class Pay { + @Test + void pay() throws SQLException { + post(admin, "!marek pay 42.5"); + + var order = database.findById(Order.class, PizzaBotTest.this.order.id()); + assertTrue(order.isPresent()); + assertEquals(42.5, order.get().payment()); + } + + @Test + void payFailsWhenNotAdmin() throws SQLException { + post(user, "!marek pay 42.5"); + + var order = database.findById(Order.class, PizzaBotTest.this.order.id()); + assertTrue(order.isPresent()); + assertEquals(PizzaBotTest.this.order, order.get()); + } + + @Test + void payWithOrderName() throws SQLException { + post(admin, "!marek pay " + order0.name() + " 42.5"); + + var order0 = database.findById(Order.class, PizzaBotTest.this.order0.id()); + assertTrue(order0.isPresent()); + assertEquals(42.5, order0.get().payment()); + + var order = database.findById(Order.class, PizzaBotTest.this.order.id()); + assertTrue(order.isPresent()); + assertNull(order.get().payment()); + } + } + + @Nested + class CheckAndSummary { + OrderItem item1; + OrderItemToUser item1ToUser; + + OrderItem item2; + OrderItemToUser item2ToUser; + + OrderItem item3; + OrderItemToUser item3ToUser; + + OrderItem item4; + OrderItemToUser item4ToUser2; + + OrderItem item5; + OrderItemToUser item5ToUser; + OrderItemToUser item5ToUser2; + + @BeforeEach + void init() throws SQLException { + item1 = database.save(new OrderItem(0, order0.id(), margherita.id(), null)); + item1ToUser = database.save(new OrderItemToUser(item1.id(), user.id(), false)); + + item2 = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + item2ToUser = database.save(new OrderItemToUser(item2.id(), user.id(), false)); + + item3 = database.save(new OrderItem(0, order.id(), salami.id(), null)); + item3ToUser = database.save(new OrderItemToUser(item3.id(), user.id(), false)); + + item4 = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + item4ToUser2 = database.save(new OrderItemToUser(item4.id(), user2.id(), false)); + + item5 = database.save(new OrderItem(0, order.id(), margherita.id(), null)); + item5ToUser = database.save(new OrderItemToUser(item5.id(), user.id(), false)); + item5ToUser2 = database.save(new OrderItemToUser(item5.id(), user2.id(), false)); + } + + @Test + void check() { + post(admin, "!marek check"); + var message = chat.getMessages().getFirst(); + var expected = FMT.""" + Rechnung für %s\{order.name()} + alle Angaben ohne Gewähr + + %s\{user.username()}: %.2f\{1.5 * margherita.price() + salami.price()} € + %s\{user2.username()}: %.2f\{1.5 * margherita.price()} € + + Gesamt: %.2f\{3 * margherita.price() + salami.price()} € + """.trim(); + assertEquals(expected, strip(message.message())); + } + + @Test + void checkTip() throws SQLException { + database.update(order.withPayment(40d)); + + post(admin, "!marek check"); + var message = chat.getMessages().getFirst(); + var expected = FMT.""" + Rechnung für %s\{order.name()} (mit Trinkgeld) + alle Angaben ohne Gewähr + + %s\{user.username()}: %.2f\{1.5 * margherita.price() + salami.price()} € (25.19 €) + %s\{user2.username()}: %.2f\{1.5 * margherita.price()} € (14.81 €) + + Gesamt: %.2f\{3 * margherita.price() + salami.price()} € (40.00 €) + """.trim(); + assertEquals(expected, strip(message.message())); + } + + @Test + void checkPayed() throws SQLException { + database.executeUpdate("UPDATE order_item_to_user SET payed = TRUE WHERE user_id = " + user.id()); + + post(admin, "!marek check"); + var message = chat.getMessages().getFirst(); + var expected = FMT.""" + Rechnung für %s\{order.name()} + alle Angaben ohne Gewähr + + %s\{user.username()}: %.2f\{1.5 * margherita.price() + salami.price()} € + %s\{user2.username()}: %.2f\{1.5 * margherita.price()} € + + Gesamt: %.2f\{3 * margherita.price() + salami.price()} € %.2f\{1.5 * margherita.price()} € + """.trim(); + assertEquals(expected, strip(message.message())); + assertEquals(strikethrough("User1: " + tabular(1.5 * margherita.price() + salami.price(), 0) + " €"), message.message().split("\n")[3]); + } + + @Test + void checkPartialPayed() throws SQLException { + database.executeUpdate("UPDATE order_item_to_user SET payed = TRUE WHERE order_item_id = " + item5.id()); + + var user1Total = 1.5 * margherita.price() + salami.price(); + var user1Remaining = margherita.price() + salami.price(); + var user2Total = 1.5 * margherita.price(); + var user2Remaining = margherita.price(); + + post(admin, "!marek check"); + var message = chat.getMessages().getFirst(); + var expected = FMT.""" + Rechnung für %s\{order.name()} + alle Angaben ohne Gewähr + + %s\{user.username()}: %.2f\{user1Total} € %.2f\{user1Remaining} € + %s\{user2.username()}: %.2f\{user2Total} € %.2f\{user2Remaining} € + + Gesamt: %.2f\{user1Total + user2Total} € %.2f\{user1Remaining + user2Remaining} € + """.trim(); + assertEquals(expected, strip(message.message())); + assertEquals( + STR."\{user.username()}: \{strikethrough(tabular(user1Total, 0)+ " €")} \{tabular(user1Remaining, 0)} €", + message.message().split("\n")[3] + ); + assertEquals( + STR."\{user2.username()}: \{strikethrough(tabular(user2Total, 0)+ " €")} \{tabular(user2Remaining, 0)} €", + message.message().split("\n")[4] + ); + } + + @Test + void checkAllTip() throws SQLException { + database.update(order.withPayment(40d)); + + post(admin, "!marek check --all"); + var message = chat.getMessages().getFirst(); + var expected = FMT.""" + Rechnung für %s\{event.name()} (mit Trinkgeld) + alle Angaben ohne Gewähr + + %s\{user.username()}: %.2f\{2.5 * margherita.price() + salami.price()} € (115.19 €) + %s\{user2.username()}: %.2f\{1.5 * margherita.price()} € (14.81 €) + + Gesamt: %.2f\{4 * margherita.price() + salami.price()} € (130.00 €) + """.trim(); + assertEquals(expected, strip(message.message())); + } + + @Test + void summary() { + post(admin, "!marek summary"); + var message = chat.getMessages().getFirst(); + var expected = FMT.""" + Übersicht %s\{order.name()} + + 3x \{margherita.name().toUpperCase(Locale.ROOT)} (\{user.username()}, \{user.username()}+\{user2.username()}, \{user2.username()}) + 1x \{salami.name().toUpperCase(Locale.ROOT)} (\{user.username()}) + """.trim(); + assertEquals(expected, strip(message.message())); + } + + @Test + void summaryAll() { + post(admin, "!marek summary --all"); + var message = chat.getMessages().getFirst(); + var expected = FMT.""" + Übersicht %s\{event.name()} + + 4x \{margherita.name().toUpperCase(Locale.ROOT)} (2x \{user.username()}, \{user.username()}+\{user2.username()}, \{user2.username()}) + 1x \{salami.name().toUpperCase(Locale.ROOT)} (\{user.username()}) + """.trim(); + assertEquals(expected, strip(message.message())); + } + } + +} diff --git a/bots/pizza-bot/src/test/java/eu/jonahbauer/chat/bot/pizza/ScriptRunner.java b/bots/pizza-bot/src/test/java/eu/jonahbauer/chat/bot/pizza/ScriptRunner.java new file mode 100644 index 0000000..e447700 --- /dev/null +++ b/bots/pizza-bot/src/test/java/eu/jonahbauer/chat/bot/pizza/ScriptRunner.java @@ -0,0 +1,248 @@ +package eu.jonahbauer.chat.bot.pizza; +/* + * Slightly modified version of the com.ibatis.common.jdbc.ScriptRunner class + * from the iBATIS Apache project. Only removed dependency on Resource class + * and a constructor + */ +/* + * Copyright 2004 Clinton Begin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.io.IOException; +import java.io.LineNumberReader; +import java.io.PrintWriter; +import java.io.Reader; +import java.sql.*; + +/** + * Tool to run database scripts + */ +@SuppressWarnings("all") +public class ScriptRunner { + + private static final String DEFAULT_DELIMITER = ";"; + + private Connection connection; + + private boolean stopOnError; + private boolean autoCommit; + + private PrintWriter logWriter = new PrintWriter(System.out); + private PrintWriter errorLogWriter = new PrintWriter(System.err); + + private String delimiter = DEFAULT_DELIMITER; + private boolean fullLineDelimiter = false; + + /** + * Default constructor + */ + public ScriptRunner(Connection connection, boolean autoCommit, + boolean stopOnError) { + this.connection = connection; + this.autoCommit = autoCommit; + this.stopOnError = stopOnError; + } + + public void setDelimiter(String delimiter, boolean fullLineDelimiter) { + this.delimiter = delimiter; + this.fullLineDelimiter = fullLineDelimiter; + } + + /** + * Setter for logWriter property + * + * @param logWriter + * - the new value of the logWriter property + */ + public void setLogWriter(PrintWriter logWriter) { + this.logWriter = logWriter; + } + + /** + * Setter for errorLogWriter property + * + * @param errorLogWriter + * - the new value of the errorLogWriter property + */ + public void setErrorLogWriter(PrintWriter errorLogWriter) { + this.errorLogWriter = errorLogWriter; + } + + /** + * Runs an SQL script (read in using the Reader parameter) + * + * @param reader + * - the source of the script + */ + public void runScript(Reader reader) throws IOException, SQLException { + try { + boolean originalAutoCommit = connection.getAutoCommit(); + try { + if (originalAutoCommit != this.autoCommit) { + connection.setAutoCommit(this.autoCommit); + } + runScript(connection, reader); + } finally { + connection.setAutoCommit(originalAutoCommit); + } + } catch (IOException e) { + throw e; + } catch (SQLException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Error running script. Cause: " + e, e); + } + } + + /** + * Runs an SQL script (read in using the Reader parameter) using the + * connection passed in + * + * @param conn + * - the connection to use for the script + * @param reader + * - the source of the script + * @throws SQLException + * if any SQL errors occur + * @throws IOException + * if there is an error reading from the Reader + */ + private void runScript(Connection conn, Reader reader) throws IOException, + SQLException { + StringBuffer command = null; + try { + LineNumberReader lineReader = new LineNumberReader(reader); + String line = null; + while ((line = lineReader.readLine()) != null) { + if (command == null) { + command = new StringBuffer(); + } + String trimmedLine = line.trim(); + if (trimmedLine.startsWith("--")) { + println(trimmedLine); + } else if (trimmedLine.length() < 1 + || trimmedLine.startsWith("//")) { + // Do nothing + } else if (trimmedLine.length() < 1 + || trimmedLine.startsWith("--")) { + // Do nothing + } else if (!fullLineDelimiter + && trimmedLine.endsWith(getDelimiter()) + || fullLineDelimiter + && trimmedLine.equals(getDelimiter())) { + command.append(line.substring(0, line + .lastIndexOf(getDelimiter()))); + command.append(" "); + Statement statement = conn.createStatement(); + + println(command); + + boolean hasResults = false; + if (stopOnError) { + hasResults = statement.execute(command.toString()); + } else { + try { + statement.execute(command.toString()); + } catch (SQLException e) { + e.fillInStackTrace(); + printlnError("Error executing: " + command); + printlnError(e); + } + } + + if (autoCommit && !conn.getAutoCommit()) { + conn.commit(); + } + + ResultSet rs = statement.getResultSet(); + if (hasResults && rs != null) { + ResultSetMetaData md = rs.getMetaData(); + int cols = md.getColumnCount(); + for (int i = 0; i < cols; i++) { + String name = md.getColumnLabel(i); + print(name + "\t"); + } + println(""); + while (rs.next()) { + for (int i = 0; i < cols; i++) { + String value = rs.getString(i); + print(value + "\t"); + } + println(""); + } + } + + command = null; + try { + statement.close(); + } catch (Exception e) { + // Ignore to workaround a bug in Jakarta DBCP + } + Thread.yield(); + } else { + command.append(line); + command.append(" "); + } + } + if (!autoCommit) { + conn.commit(); + } + } catch (SQLException e) { + e.fillInStackTrace(); + printlnError("Error executing: " + command); + printlnError(e); + throw e; + } catch (IOException e) { + e.fillInStackTrace(); + printlnError("Error executing: " + command); + printlnError(e); + throw e; + } finally { + conn.rollback(); + flush(); + } + } + + private String getDelimiter() { + return delimiter; + } + + private void print(Object o) { + if (logWriter != null) { + System.out.print(o); + } + } + + private void println(Object o) { + if (logWriter != null) { + logWriter.println(o); + } + } + + private void printlnError(Object o) { + if (errorLogWriter != null) { + errorLogWriter.println(o); + } + } + + private void flush() { + if (logWriter != null) { + logWriter.flush(); + } + if (errorLogWriter != null) { + errorLogWriter.flush(); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 6cd35f8..82e1659 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,9 @@ include("management") include("bots:ping-bot") project(":bots:ping-bot").name = "ping-bot" +include("bots:pizza-bot") +project(":bots:pizza-bot").name = "pizza-bot" + dependencyResolutionManagement { repositories { mavenCentral()