diff --git a/.gitignore b/.gitignore index 7c30bee..2b385c4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,7 @@ build database.mv.db database.trace.db db/ +!src/main/resources/db/ +!src/main/resources/db/migration/ +!src/main/resources/db/migration/** .DS_Store diff --git a/build.gradle.kts b/build.gradle.kts index f45d9ba..36693cb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") implementation("org.springframework.boot:spring-boot-starter-security") runtimeOnly("com.h2database:h2") // for local runtimeOnly("org.postgresql:postgresql") // for prod diff --git a/src/main/java/io/papermc/patchroulette/controller/ApiController.java b/src/main/java/io/papermc/patchroulette/controller/ApiController.java index a4cd560..5d156a9 100644 --- a/src/main/java/io/papermc/patchroulette/controller/ApiController.java +++ b/src/main/java/io/papermc/patchroulette/controller/ApiController.java @@ -7,7 +7,7 @@ import io.papermc.patchroulette.util.TimeUtil; import jakarta.persistence.EntityNotFoundException; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -50,7 +50,7 @@ public ResponseEntity> getAvailablePatches(@RequestParam final Stri ); } - public record PatchDetails(String path, String status, String responsibleUser, LocalDateTime lastUpdated, Duration duration) {} + public record PatchDetails(String path, String status, String responsibleUser, Instant lastUpdated, Duration duration) {} @PreAuthorize("hasRole('PATCH')") @GetMapping( @@ -60,7 +60,13 @@ public record PatchDetails(String path, String status, String responsibleUser, L public ResponseEntity> getAllPatches(@RequestParam final String minecraftVersion) { return ResponseEntity.ok( this.patchService.getAllPatches(minecraftVersion).stream() - .map(patch -> new PatchDetails(patch.getPath(), patch.getStatus().name(), patch.getResponsibleUser(), patch.getLastUpdated(), patch.getDuration())) + .map(patch -> new PatchDetails( + patch.getPath(), + patch.getStatus().name(), + patch.getResponsibleUser(), + patch.getLastUpdated(), + patch.getDuration() + )) .toList() ); } @@ -232,8 +238,8 @@ public ResponseEntity stats(@RequestParam final String minecraftVersion) // Track the time interval for this patch if it has duration if (patch.getDuration() != null && patch.getLastUpdated() != null) { - LocalDateTime endTime = patch.getLastUpdated(); - LocalDateTime startTime = endTime.minus(patch.getDuration()); + Instant endTime = patch.getLastUpdated(); + Instant startTime = endTime.minus(patch.getDuration()); userIntervals.computeIfAbsent(patch.getResponsibleUser(), k -> new ArrayList<>()) .add(new TimeUtil.TimeInterval(startTime, endTime)); diff --git a/src/main/java/io/papermc/patchroulette/model/Patch.java b/src/main/java/io/papermc/patchroulette/model/Patch.java index 38cd2ac..952bee9 100644 --- a/src/main/java/io/papermc/patchroulette/model/Patch.java +++ b/src/main/java/io/papermc/patchroulette/model/Patch.java @@ -8,7 +8,7 @@ import jakarta.persistence.IdClass; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @IdClass(PatchId.class) @@ -25,7 +25,7 @@ public class Patch { private Status status; private String responsibleUser; - private LocalDateTime lastUpdated; + private Instant lastUpdated; private Duration duration; public Patch() { @@ -63,11 +63,11 @@ public void setResponsibleUser(final String responsibleUser) { this.responsibleUser = responsibleUser; } - public LocalDateTime getLastUpdated() { + public Instant getLastUpdated() { return lastUpdated; } - public void setLastUpdated(LocalDateTime lastUpdated) { + public void setLastUpdated(Instant lastUpdated) { this.lastUpdated = lastUpdated; } @@ -81,7 +81,7 @@ public void setDuration(Duration duration) { public void updateDuration() { if (this.lastUpdated != null) { - final Duration duration = Duration.between(this.lastUpdated, LocalDateTime.now()); + final Duration duration = Duration.between(this.lastUpdated, Instant.now()); if (this.duration == null) { this.duration = duration; } else { diff --git a/src/main/java/io/papermc/patchroulette/service/PatchService.java b/src/main/java/io/papermc/patchroulette/service/PatchService.java index 5f097bf..22c88c6 100644 --- a/src/main/java/io/papermc/patchroulette/service/PatchService.java +++ b/src/main/java/io/papermc/patchroulette/service/PatchService.java @@ -5,7 +5,7 @@ import io.papermc.patchroulette.model.Status; import io.papermc.patchroulette.repository.PatchRepository; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; @@ -29,7 +29,7 @@ public void setPatches(final String minecraftVersion, final List paths) patch.setPath(path); patch.setStatus(Status.AVAILABLE); patch.setMinecraftVersion(minecraftVersion); - patch.setLastUpdated(LocalDateTime.now()); + patch.setLastUpdated(Instant.now()); return patch; }).toList(); @@ -58,7 +58,7 @@ public List startWorkOnPatches(final String minecraftVersion, final List } patch.setStatus(Status.WIP); patch.setResponsibleUser(user); - patch.setLastUpdated(LocalDateTime.now()); + patch.setLastUpdated(Instant.now()); this.patchRepository.save(patch); startedPatches.add(path); } @@ -74,7 +74,7 @@ public void cancelWorkOnPatch(final PatchId patchId) { patch.setStatus(Status.AVAILABLE); patch.setResponsibleUser(null); patch.setDuration(null); - patch.setLastUpdated(LocalDateTime.now()); + patch.setLastUpdated(Instant.now()); this.patchRepository.save(patch); } @@ -89,7 +89,7 @@ public void finishWorkOnPatch(final PatchId patchId, final String user) { } patch.setStatus(Status.DONE); patch.updateDuration(); - patch.setLastUpdated(LocalDateTime.now()); + patch.setLastUpdated(Instant.now()); this.patchRepository.save(patch); } @@ -101,7 +101,7 @@ public void undoPatch(final PatchId patchId, final String user) { } patch.setStatus(Status.WIP); patch.setResponsibleUser(user); - patch.setLastUpdated(LocalDateTime.now()); + patch.setLastUpdated(Instant.now()); this.patchRepository.save(patch); } diff --git a/src/main/java/io/papermc/patchroulette/util/TimeUtil.java b/src/main/java/io/papermc/patchroulette/util/TimeUtil.java index 1e54fce..4feeebe 100644 --- a/src/main/java/io/papermc/patchroulette/util/TimeUtil.java +++ b/src/main/java/io/papermc/patchroulette/util/TimeUtil.java @@ -1,7 +1,7 @@ package io.papermc.patchroulette.util; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -9,7 +9,7 @@ public final class TimeUtil { private TimeUtil() { } - public record TimeInterval(LocalDateTime start, LocalDateTime end) {} + public record TimeInterval(Instant start, Instant end) {} public static List mergeOverlappingIntervals(List intervals) { if (intervals.isEmpty()) { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index edd529d..f8800da 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -13,6 +13,13 @@ spring: username: admin password: password driver-class-name: org.h2.Driver + flyway: + baseline-on-migrate: true + baseline-version: 1 + locations: classpath:db/migration/{vendor} + placeholders: + # Timezone used by the old LocalDateTime values during legacy migration. + legacy_timezone: UTC jpa: hibernate: - ddl-auto: update + ddl-auto: validate diff --git a/src/main/resources/db/migration/h2/V1__create_patch_table.sql b/src/main/resources/db/migration/h2/V1__create_patch_table.sql new file mode 100644 index 0000000..9137789 --- /dev/null +++ b/src/main/resources/db/migration/h2/V1__create_patch_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS patch ( + minecraft_version VARCHAR(255) NOT NULL, + path VARCHAR(1024) NOT NULL, + status TINYINT, + responsible_user VARCHAR(255), + last_updated TIMESTAMP(6), + duration NUMERIC(21), + PRIMARY KEY (minecraft_version, path) +); diff --git a/src/main/resources/db/migration/h2/V2__convert_last_updated_to_utc.sql b/src/main/resources/db/migration/h2/V2__convert_last_updated_to_utc.sql new file mode 100644 index 0000000..16bbf20 --- /dev/null +++ b/src/main/resources/db/migration/h2/V2__convert_last_updated_to_utc.sql @@ -0,0 +1,4 @@ +-- Legacy LocalDateTime values are interpreted using the deployment timezone. +ALTER TABLE patch + ALTER COLUMN last_updated TIMESTAMP(6) WITH TIME ZONE + USING CAST(FORMATDATETIME(last_updated, 'yyyy-MM-dd HH:mm:ss.SSSSSS') || ' ${legacy_timezone}' AS TIMESTAMP(6) WITH TIME ZONE); diff --git a/src/main/resources/db/migration/postgresql/V1__create_patch_table.sql b/src/main/resources/db/migration/postgresql/V1__create_patch_table.sql new file mode 100644 index 0000000..c1396c5 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V1__create_patch_table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS patch ( + minecraft_version VARCHAR(255) NOT NULL, + path VARCHAR(1024) NOT NULL, + status SMALLINT, + responsible_user VARCHAR(255), + last_updated TIMESTAMP(6), + duration NUMERIC(21), + PRIMARY KEY (minecraft_version, path) +); diff --git a/src/main/resources/db/migration/postgresql/V2__convert_last_updated_to_utc.sql b/src/main/resources/db/migration/postgresql/V2__convert_last_updated_to_utc.sql new file mode 100644 index 0000000..382b9c7 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V2__convert_last_updated_to_utc.sql @@ -0,0 +1,4 @@ +-- Legacy LocalDateTime values are interpreted using the deployment timezone. +ALTER TABLE patch + ALTER COLUMN last_updated TYPE TIMESTAMP(6) WITH TIME ZONE + USING last_updated AT TIME ZONE '${legacy_timezone}'; diff --git a/web/src/lib/components/PatchesTable.svelte b/web/src/lib/components/PatchesTable.svelte index b781183..d1302ce 100644 --- a/web/src/lib/components/PatchesTable.svelte +++ b/web/src/lib/components/PatchesTable.svelte @@ -53,7 +53,9 @@ filter: true, floatingFilter: true, valueFormatter: (params) => - params.data?.lastUpdated ? DateTime.fromISO(params.data.lastUpdated).toLocal().toLocaleString(DateTime.DATETIME_SHORT) : "", + params.data?.lastUpdated + ? DateTime.fromISO(params.data.lastUpdated, { zone: "utc" }).toLocal().toLocaleString(DateTime.DATETIME_SHORT) + : "", }, ], getRowId: (params) => params.data.path, diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e2a5c65..c4ded43 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -8,7 +8,7 @@ export type PatchDetails = { path: string; status: PatchStatus; responsibleUser: string; - lastUpdated: string; + lastUpdated: string; // ISO 8601 UTC datetime duration: string; };