diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 5846b0bf..992d060f 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -4,5 +4,6 @@ plugins { dependencies { api(libs.configurate) + compileOnly(libs.bundles.database) compileOnly("com.fasterxml.jackson.core", "jackson-annotations", "2.16.1") } diff --git a/api/src/main/java/love/broccolai/tickets/api/service/StorageService.java b/api/src/main/java/love/broccolai/tickets/api/service/StorageService.java index 644d954b..6bf1d08f 100644 --- a/api/src/main/java/love/broccolai/tickets/api/service/StorageService.java +++ b/api/src/main/java/love/broccolai/tickets/api/service/StorageService.java @@ -1,5 +1,6 @@ package love.broccolai.tickets.api.service; +import com.impossibl.postgres.api.jdbc.PGNotificationListener; import java.time.Instant; import java.util.Collection; import java.util.Map; @@ -16,6 +17,8 @@ @NullMarked public interface StorageService { + void addNotificationListener(PGNotificationListener listener); + Ticket createTicket(TicketType type, UUID creator, String message); void saveAction(Ticket ticket, Action action); diff --git a/common/src/main/java/love/broccolai/tickets/common/configuration/DatabaseConfiguration.java b/common/src/main/java/love/broccolai/tickets/common/configuration/DatabaseConfiguration.java index 38f51f7f..588ff515 100644 --- a/common/src/main/java/love/broccolai/tickets/common/configuration/DatabaseConfiguration.java +++ b/common/src/main/java/love/broccolai/tickets/common/configuration/DatabaseConfiguration.java @@ -22,7 +22,7 @@ public static class H2 { @ConfigSerializable public static class Postgres { - public String url = "jdbc:postgresql://localhost:5432/database"; + public String url = "jdbc:pgsql://localhost:5432/database"; public String username = "username"; diff --git a/common/src/main/java/love/broccolai/tickets/common/inject/ConfigurationModule.java b/common/src/main/java/love/broccolai/tickets/common/inject/ConfigurationModule.java index b3ccafc2..ccc9b89d 100644 --- a/common/src/main/java/love/broccolai/tickets/common/inject/ConfigurationModule.java +++ b/common/src/main/java/love/broccolai/tickets/common/inject/ConfigurationModule.java @@ -26,7 +26,6 @@ import org.jdbi.v3.core.Jdbi; import org.jdbi.v3.gson2.Gson2Config; import org.jdbi.v3.gson2.Gson2Plugin; -import org.jdbi.v3.postgres.PostgresPlugin; import org.jspecify.annotations.NullMarked; import org.spongepowered.configurate.ConfigurationNode; import org.spongepowered.configurate.hocon.HoconConfigurationLoader; @@ -59,7 +58,7 @@ public DataSource provideDataSource( hikariConfig.setJdbcUrl(configuration.h2.url); } case POSTGRES -> { - hikariConfig.setDriverClassName("org.postgresql.Driver"); + hikariConfig.setDriverClassName("com.impossibl.postgres.jdbc.PGDriver"); hikariConfig.setJdbcUrl(configuration.postgres.url); hikariConfig.setUsername(configuration.postgres.username); hikariConfig.setPassword(configuration.postgres.password); @@ -81,7 +80,6 @@ public Jdbi provideJdbi( ) { Jdbi jdbi = Jdbi.create(dataSource) .installPlugin(new Gson2Plugin()) - .installPlugin(new PostgresPlugin()) .registerRowMapper(actionMapper) .registerRowMapper(ticketMapper) .registerColumnMapper(ticketTypeMapper) diff --git a/common/src/main/java/love/broccolai/tickets/common/service/DatabaseStorageService.java b/common/src/main/java/love/broccolai/tickets/common/service/DatabaseStorageService.java index 46847610..b88fb95a 100644 --- a/common/src/main/java/love/broccolai/tickets/common/service/DatabaseStorageService.java +++ b/common/src/main/java/love/broccolai/tickets/common/service/DatabaseStorageService.java @@ -3,6 +3,10 @@ import com.google.common.primitives.Ints; import com.google.inject.Inject; import com.google.inject.Singleton; +import com.impossibl.postgres.api.jdbc.PGConnection; +import com.impossibl.postgres.api.jdbc.PGNotificationListener; +import java.sql.Connection; +import java.sql.SQLException; import java.time.Instant; import java.util.Collection; import java.util.LinkedHashSet; @@ -25,6 +29,7 @@ import love.broccolai.tickets.common.serialization.jdbi.TicketAccumulator; import love.broccolai.tickets.common.utilities.QueriesLocator; import love.broccolai.tickets.common.utilities.TimeUtilities; +import org.jdbi.v3.core.Handle; import org.jdbi.v3.core.Jdbi; import org.jdbi.v3.core.qualifier.QualifiedType; import org.jdbi.v3.core.statement.Update; @@ -55,6 +60,25 @@ public DatabaseStorageService( this.locator = new QueriesLocator(configuration.type); } + //todo: implement listen / channel via statements. + @Override + public void addNotificationListener(final PGNotificationListener listener) { + this.jdbi.useHandle(handle -> { + PGConnection connection = this.connectionFromHandle(handle); + logger.trace("Adding notification listener: {}", listener.getClass().getSimpleName()); + + connection.addNotificationListener(listener); + }); + } + + private PGConnection connectionFromHandle(final Handle handle) { + try (Connection conn = handle.getConnection()) { + return conn.unwrap(PGConnection.class); + } catch (SQLException e) { + throw new RuntimeException("Error obtaining PGConnection", e); + } + } + @Override public Ticket createTicket(final TicketType type, final UUID creator, final String message) { Instant timestamp = TimeUtilities.nowTruncated(); diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 880810bb..c8ff655e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ hikari = "5.1.0" flyway = "10.11.0" h2 = "2.2.224" configurate = "4.1.2" -postgresql = "42.7.3" +postgresql = "0.8.9" # Misc corn = "4.0.0-SNAPSHOT" @@ -79,7 +79,7 @@ flyway = { group = "org.flywaydb", name = "flyway-core", version.ref = "flyway" flyway-database-postgresql = { group = "org.flywaydb", name = "flyway-database-postgresql", version.ref = "flyway" } h2 = { group = "com.h2database", name = "h2", version.ref = "h2" } configurate = { group = "org.spongepowered", name = "configurate-hocon", version.ref = "configurate" } -postgresql = { group = "org.postgresql", name = "postgresql", version.ref = "postgresql" } +postgresql = { group = "com.impossibl.pgjdbc-ng", name = "pgjdbc-ng", version.ref = "postgresql" } gson = { group = "com.google.code.gson", name = "gson", version = "2.10.1" } # Misc diff --git a/minecraft/common/src/main/java/love/broccolai/tickets/minecraft/common/command/AdminCommands.java b/minecraft/common/src/main/java/love/broccolai/tickets/minecraft/common/command/AdminCommands.java new file mode 100644 index 00000000..4f1dae88 --- /dev/null +++ b/minecraft/common/src/main/java/love/broccolai/tickets/minecraft/common/command/AdminCommands.java @@ -0,0 +1,55 @@ +package love.broccolai.tickets.minecraft.common.command; + +import com.google.inject.Inject; +import java.time.Duration; +import love.broccolai.tickets.api.service.StatisticService; +import love.broccolai.tickets.minecraft.common.model.Commander; +import love.broccolai.tickets.minecraft.common.utilities.DurationFormatter; +import org.incendo.cloud.Command; +import org.incendo.cloud.CommandManager; +import org.incendo.cloud.context.CommandContext; +import org.incendo.cloud.key.CloudKey; +import org.incendo.cloud.parser.standard.DurationParser; +import org.jspecify.annotations.NullMarked; + +import static net.kyori.adventure.text.Component.text; + +@NullMarked +public final class AdminCommands extends AbstractCommand { + + private final static CloudKey DURATION_KEY = CloudKey.cloudKey("duration", Duration.class); + private final static Duration FOREVER_DURATION = Duration.ofSeconds(Long.MAX_VALUE); + + private final StatisticService statisticService; + + @Inject + public AdminCommands(final StatisticService statisticService) { + this.statisticService = statisticService; + } + + @Override + public void register(final CommandManager commandManager) { + Command.Builder root = commandManager + .commandBuilder("ticketsadmin"); + + commandManager.command( + root.literal("stats") + .literal("lifespan") + .optional("duration", DurationParser.durationParser()) + .handler(this::handleLifespan) + ); + } + + public void handleLifespan(final CommandContext context) { + Commander commander = context.sender(); + Duration search = context.optional(DURATION_KEY) + .orElse(FOREVER_DURATION); + + Duration result = this.statisticService.averageTicketsLifespan(search); + String formattedResult = DurationFormatter.formatDuration(result); + + commander.sendMessage( + text("average ticket lifespan: " + formattedResult) + ); + } +} diff --git a/minecraft/common/src/main/java/love/broccolai/tickets/minecraft/common/listener/ActionListener.java b/minecraft/common/src/main/java/love/broccolai/tickets/minecraft/common/listener/ActionListener.java new file mode 100644 index 00000000..70dd4557 --- /dev/null +++ b/minecraft/common/src/main/java/love/broccolai/tickets/minecraft/common/listener/ActionListener.java @@ -0,0 +1,11 @@ +package love.broccolai.tickets.minecraft.common.listener; + +import com.impossibl.postgres.api.jdbc.PGNotificationListener; + +public final class ActionListener implements PGNotificationListener { + + @Override + public void notification(int processId, String channelName, String payload) { + System.out.println(payload); + } +} diff --git a/minecraft/common/src/main/java/love/broccolai/tickets/minecraft/common/utilities/DurationFormatter.java b/minecraft/common/src/main/java/love/broccolai/tickets/minecraft/common/utilities/DurationFormatter.java new file mode 100644 index 00000000..ad614ad9 --- /dev/null +++ b/minecraft/common/src/main/java/love/broccolai/tickets/minecraft/common/utilities/DurationFormatter.java @@ -0,0 +1,33 @@ +package love.broccolai.tickets.minecraft.common.utilities; + +import java.time.Duration; + +public final class DurationFormatter { + + private DurationFormatter() { + } + + /** + * Formats a Duration object into a string of format "Xh Ym". + * + * @param duration The Duration object to format. + * @return A formatted string representing the duration in hours and minutes. + */ + public static String formatDuration(final Duration duration) { + if (duration.isZero()) { + return "unknown"; + } + + long hours = duration.toHours(); + long minutes = duration.toMinutesPart(); + + StringBuilder sb = new StringBuilder(); + if (hours > 0) { + sb.append(hours).append("h "); + } + if (minutes > 0) { + sb.append(minutes).append("m"); + } + return sb.toString().trim(); + } +} diff --git a/minecraft/paper/src/main/java/love/broccolai/tickets/minecraft/paper/PaperTicketsPlugin.java b/minecraft/paper/src/main/java/love/broccolai/tickets/minecraft/paper/PaperTicketsPlugin.java index 88848721..f749ea9f 100644 --- a/minecraft/paper/src/main/java/love/broccolai/tickets/minecraft/paper/PaperTicketsPlugin.java +++ b/minecraft/paper/src/main/java/love/broccolai/tickets/minecraft/paper/PaperTicketsPlugin.java @@ -3,10 +3,14 @@ import com.google.inject.Injector; import com.google.inject.Key; import com.google.inject.TypeLiteral; +import love.broccolai.tickets.api.service.StorageService; import love.broccolai.tickets.common.TicketsPackage; +import love.broccolai.tickets.common.configuration.DatabaseConfiguration; +import love.broccolai.tickets.minecraft.common.command.AdminCommands; import love.broccolai.tickets.minecraft.common.command.StaffCommands; import love.broccolai.tickets.minecraft.common.command.UserCommands; import love.broccolai.tickets.minecraft.common.inject.CommandArgumentModule; +import love.broccolai.tickets.minecraft.common.listener.ActionListener; import love.broccolai.tickets.minecraft.common.model.Commander; import love.broccolai.tickets.minecraft.paper.inject.PaperPlatformModule; import org.bukkit.plugin.java.JavaPlugin; @@ -33,5 +37,23 @@ public void onEnable() { injector.getInstance(UserCommands.class).register(commandManager); injector.getInstance(StaffCommands.class).register(commandManager); + injector.getInstance(AdminCommands.class).register(commandManager); + + this.setupNotifications(injector); + } + + //todo: add non-notif alternative for H2. + public void setupNotifications(final Injector injector) { + DatabaseConfiguration databaseConfiguration = injector.getInstance(DatabaseConfiguration.class); + + if (databaseConfiguration.type != DatabaseConfiguration.Type.POSTGRES) { + return; + } + + StorageService storageService = injector.getInstance(StorageService.class); + + storageService.addNotificationListener( + injector.getInstance(ActionListener.class) + ); } }