diff --git a/src/main/java/io/papermc/patchroulette/controller/ApiController.java b/src/main/java/io/papermc/patchroulette/controller/ApiController.java index c275773..cf744cd 100644 --- a/src/main/java/io/papermc/patchroulette/controller/ApiController.java +++ b/src/main/java/io/papermc/patchroulette/controller/ApiController.java @@ -2,7 +2,7 @@ import io.papermc.patchroulette.model.Patch; import io.papermc.patchroulette.model.PatchId; -import io.papermc.patchroulette.model.Status; +import io.papermc.patchroulette.model.PatchState; import io.papermc.patchroulette.service.PatchService; import io.papermc.patchroulette.util.TimeUtil; import jakarta.persistence.EntityNotFoundException; @@ -13,7 +13,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -44,7 +43,7 @@ public ApiController(final PatchService patchService) { public ResponseEntity> getAvailablePatches( @RequestParam final String minecraftVersion) { return ResponseEntity.ok(this.patchService.getAvailablePatches(minecraftVersion).stream() - .map(Patch::getPath) + .map(patch -> patch.id().getPath()) .toList()); } @@ -55,17 +54,30 @@ public record PatchDetails( @Nullable Instant lastUpdated, @Nullable Duration duration) {} + private static PatchDetails toPatchDetails(final Patch patch) { + final PatchState state = patch.state(); + return switch (state) { + case PatchState.Available a -> + new PatchDetails(patch.id().getPath(), state.label(), null, patch.lastUpdated(), null); + case PatchState.InProgress w -> + new PatchDetails( + patch.id().getPath(), state.label(), w.responsibleUser(), patch.lastUpdated(), null); + case PatchState.Completed c -> + new PatchDetails( + patch.id().getPath(), + state.label(), + c.responsibleUser(), + patch.lastUpdated(), + c.duration()); + }; + } + @PreAuthorize("hasRole('PATCH')") @GetMapping(value = "/get-all-patches", produces = "application/json") 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(ApiController::toPatchDetails) .toList()); } @@ -163,6 +175,7 @@ public static class UserStats { public long wip; public long done; public Duration timeSpent; + private final List intervals = new ArrayList<>(); public UserStats(String user, long wip, long done, Duration timeSpent) { this.user = user; @@ -182,64 +195,37 @@ public ResponseEntity stats(@RequestParam final String minecraftVersion) long done = 0; final Map users = new HashMap<>(); - // Track intervals for each user - final Map> userIntervals = new HashMap<>(); - - for (Patch patch : allPatches) { - final Status status = patch.getStatus(); - switch (status) { - case AVAILABLE -> available++; - case WIP -> wip++; - case DONE -> done++; - } - - final String responsibleUser = patch.getResponsibleUser(); - if (responsibleUser != null) { - users.compute(responsibleUser, (user, userStats) -> { - if (userStats == null) { - userStats = new UserStats(responsibleUser, 0, 0, Duration.ZERO); - } - if (status == Status.WIP) { - userStats.wip++; - } else if (status == Status.DONE) { - userStats.done++; + for (final Patch patch : allPatches) { + switch (patch.state()) { + case PatchState.Available a -> available++; + case PatchState.InProgress w -> { + wip++; + users.computeIfAbsent( + w.responsibleUser(), user -> new UserStats(user, 0, 0, Duration.ZERO)) + .wip++; + } + case PatchState.Completed c -> { + done++; + final UserStats userStats = users.computeIfAbsent( + c.responsibleUser(), user -> new UserStats(user, 0, 0, Duration.ZERO)); + userStats.done++; + final Instant lastUpdated = patch.lastUpdated(); + if (lastUpdated != null) { + userStats.intervals.add( + new TimeUtil.TimeInterval(lastUpdated.minus(c.duration()), lastUpdated)); } - return userStats; - }); - - // Track the time interval for this patch if it has duration - final Duration duration = patch.getDuration(); - final Instant lastUpdated = patch.getLastUpdated(); - if (duration != null && lastUpdated != null) { - final Instant startTime = lastUpdated.minus(duration); - - userIntervals - .computeIfAbsent(responsibleUser, k -> new ArrayList<>()) - .add(new TimeUtil.TimeInterval(startTime, lastUpdated)); } } } // Calculate accurate time spent for each user by merging overlapping intervals Duration totalTimeSpent = Duration.ZERO; - for (Map.Entry> entry : userIntervals.entrySet()) { - String user = entry.getKey(); - List intervals = entry.getValue(); - - // Sort intervals by start time + for (final UserStats userStats : users.values()) { + final List intervals = userStats.intervals; intervals.sort(Comparator.comparing(TimeUtil.TimeInterval::start)); - - // Merge overlapping intervals - List mergedIntervals = TimeUtil.mergeOverlappingIntervals(intervals); - - // Calculate total duration from merged intervals - Duration userDuration = TimeUtil.calculateDuration(mergedIntervals); - - // Present for every userIntervals key: both maps are populated together above. - final UserStats userStats = Objects.requireNonNull(users.get(user)); + final Duration userDuration = + TimeUtil.calculateDuration(TimeUtil.mergeOverlappingIntervals(intervals)); userStats.timeSpent = userDuration; - - // Add to total time totalTimeSpent = totalTimeSpent.plus(userDuration); } diff --git a/src/main/java/io/papermc/patchroulette/model/Patch.java b/src/main/java/io/papermc/patchroulette/model/Patch.java index d492518..ee6e3e6 100644 --- a/src/main/java/io/papermc/patchroulette/model/Patch.java +++ b/src/main/java/io/papermc/patchroulette/model/Patch.java @@ -1,96 +1,7 @@ package io.papermc.patchroulette.model; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.Id; -import jakarta.persistence.IdClass; -import java.time.Duration; import java.time.Instant; import org.jspecify.annotations.Nullable; -@Entity -@IdClass(PatchId.class) -public class Patch { - - @Id - private String minecraftVersion; - - @Id - @Column(columnDefinition = "VARCHAR(1024)") - private String path; - - @Enumerated(EnumType.ORDINAL) - private Status status; - - @Nullable - private String responsibleUser; - - @Nullable - private Instant lastUpdated; - - @Nullable - private Duration duration; - - public Patch() {} - - public String getMinecraftVersion() { - return this.minecraftVersion; - } - - public void setMinecraftVersion(final String minecraftVersion) { - this.minecraftVersion = minecraftVersion; - } - - public String getPath() { - return this.path; - } - - public void setPath(final String path) { - this.path = path; - } - - public Status getStatus() { - return this.status; - } - - public void setStatus(final Status status) { - this.status = status; - } - - public @Nullable String getResponsibleUser() { - return this.responsibleUser; - } - - public void setResponsibleUser(final @Nullable String responsibleUser) { - this.responsibleUser = responsibleUser; - } - - public @Nullable Instant getLastUpdated() { - return lastUpdated; - } - - public void setLastUpdated(final @Nullable Instant lastUpdated) { - this.lastUpdated = lastUpdated; - } - - public @Nullable Duration getDuration() { - return duration; - } - - public void setDuration(final @Nullable Duration duration) { - this.duration = duration; - } - - public void updateDuration() { - if (this.lastUpdated != null) { - final Duration elapsed = Duration.between(this.lastUpdated, Instant.now()); - if (this.duration == null) { - this.duration = elapsed; - } else { - this.duration = this.duration.plus(elapsed); - } - } - } -} +/** Domain model of a patch, persisted via {@code repository.PatchEntity}. */ +public record Patch(PatchId id, PatchState state, @Nullable Instant lastUpdated) {} diff --git a/src/main/java/io/papermc/patchroulette/model/PatchState.java b/src/main/java/io/papermc/patchroulette/model/PatchState.java new file mode 100644 index 0000000..76b6783 --- /dev/null +++ b/src/main/java/io/papermc/patchroulette/model/PatchState.java @@ -0,0 +1,36 @@ +package io.papermc.patchroulette.model; + +import java.time.Duration; +import java.time.Instant; + +/** + * Lifecycle state of a patch. Each state carries the data that is only valid in + * that state, so invalid combinations (e.g. a WIP patch without a responsible + * user) are unrepresentable. + */ +public sealed interface PatchState { + + /** The status label exposed by the API. */ + String label(); + + record Available() implements PatchState { + @Override + public String label() { + return "AVAILABLE"; + } + } + + record InProgress(String responsibleUser, Instant startedAt) implements PatchState { + @Override + public String label() { + return "WIP"; + } + } + + record Completed(String responsibleUser, Duration duration) implements PatchState { + @Override + public String label() { + return "DONE"; + } + } +} diff --git a/src/main/java/io/papermc/patchroulette/model/StateType.java b/src/main/java/io/papermc/patchroulette/model/StateType.java new file mode 100644 index 0000000..94c8882 --- /dev/null +++ b/src/main/java/io/papermc/patchroulette/model/StateType.java @@ -0,0 +1,8 @@ +package io.papermc.patchroulette.model; + +/** Discriminator for {@link PatchState}, stored in the state_type column. */ +public enum StateType { + AVAILABLE, + WIP, + DONE, +} diff --git a/src/main/java/io/papermc/patchroulette/model/Status.java b/src/main/java/io/papermc/patchroulette/model/Status.java deleted file mode 100644 index 896ec8c..0000000 --- a/src/main/java/io/papermc/patchroulette/model/Status.java +++ /dev/null @@ -1,7 +0,0 @@ -package io.papermc.patchroulette.model; - -public enum Status { - AVAILABLE, - WIP, - DONE -} diff --git a/src/main/java/io/papermc/patchroulette/repository/PatchCodec.java b/src/main/java/io/papermc/patchroulette/repository/PatchCodec.java new file mode 100644 index 0000000..47d4749 --- /dev/null +++ b/src/main/java/io/papermc/patchroulette/repository/PatchCodec.java @@ -0,0 +1,59 @@ +package io.papermc.patchroulette.repository; + +import io.papermc.patchroulette.model.Patch; +import io.papermc.patchroulette.model.PatchId; +import io.papermc.patchroulette.model.PatchState; +import io.papermc.patchroulette.model.StateType; +import java.util.Objects; + +/** Converts between the domain {@link Patch} and the JPA {@link PatchEntity}. */ +public final class PatchCodec { + private PatchCodec() {} + + public static Patch toDomain(final PatchEntity entity) { + return new Patch( + new PatchId(entity.getMinecraftVersion(), entity.getPath()), + switch (entity.getStateType()) { + case AVAILABLE -> new PatchState.Available(); + case WIP -> + new PatchState.InProgress( + Objects.requireNonNull( + entity.getResponsibleUser(), "WIP patch missing responsible user"), + Objects.requireNonNull(entity.getStartedAt(), "WIP patch missing start time")); + case DONE -> + new PatchState.Completed( + Objects.requireNonNull( + entity.getResponsibleUser(), "DONE patch missing responsible user"), + Objects.requireNonNull(entity.getDuration(), "DONE patch missing duration")); + }, + entity.getLastUpdated()); + } + + public static PatchEntity toEntity(final Patch patch) { + final PatchEntity entity = new PatchEntity(); + entity.setMinecraftVersion(patch.id().getMinecraftVersion()); + entity.setPath(patch.id().getPath()); + switch (patch.state()) { + case PatchState.Available a -> { + entity.setStateType(StateType.AVAILABLE); + entity.setResponsibleUser(null); + entity.setStartedAt(null); + entity.setDuration(null); + } + case PatchState.InProgress w -> { + entity.setStateType(StateType.WIP); + entity.setResponsibleUser(w.responsibleUser()); + entity.setStartedAt(w.startedAt()); + entity.setDuration(null); + } + case PatchState.Completed c -> { + entity.setStateType(StateType.DONE); + entity.setResponsibleUser(c.responsibleUser()); + entity.setStartedAt(null); + entity.setDuration(c.duration()); + } + } + entity.setLastUpdated(patch.lastUpdated()); + return entity; + } +} diff --git a/src/main/java/io/papermc/patchroulette/repository/PatchEntity.java b/src/main/java/io/papermc/patchroulette/repository/PatchEntity.java new file mode 100644 index 0000000..ce2bb8c --- /dev/null +++ b/src/main/java/io/papermc/patchroulette/repository/PatchEntity.java @@ -0,0 +1,105 @@ +package io.papermc.patchroulette.repository; + +import io.papermc.patchroulette.model.PatchId; +import io.papermc.patchroulette.model.StateType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import java.time.Duration; +import java.time.Instant; +import org.jspecify.annotations.Nullable; + +@Entity +@Table(name = "patch") +@IdClass(PatchId.class) +public class PatchEntity { + + @Id + private String minecraftVersion; + + @Id + @Column(columnDefinition = "VARCHAR(1024)") + private String path; + + @Enumerated(EnumType.STRING) + @Column(name = "state_type", nullable = false, length = 16) + private StateType stateType; + + @Nullable + @Column(name = "responsible_user") + private String responsibleUser; + + @Nullable + @Column(name = "started_at") + private Instant startedAt; + + @Nullable + @Column(name = "duration") + private Duration duration; + + @Nullable + @Column(name = "last_updated") + private Instant lastUpdated; + + public PatchEntity() {} + + public String getMinecraftVersion() { + return this.minecraftVersion; + } + + public void setMinecraftVersion(final String minecraftVersion) { + this.minecraftVersion = minecraftVersion; + } + + public String getPath() { + return this.path; + } + + public void setPath(final String path) { + this.path = path; + } + + public StateType getStateType() { + return this.stateType; + } + + public void setStateType(final StateType stateType) { + this.stateType = stateType; + } + + public @Nullable String getResponsibleUser() { + return this.responsibleUser; + } + + public void setResponsibleUser(final @Nullable String responsibleUser) { + this.responsibleUser = responsibleUser; + } + + public @Nullable Instant getStartedAt() { + return this.startedAt; + } + + public void setStartedAt(final @Nullable Instant startedAt) { + this.startedAt = startedAt; + } + + public @Nullable Duration getDuration() { + return this.duration; + } + + public void setDuration(final @Nullable Duration duration) { + this.duration = duration; + } + + public @Nullable Instant getLastUpdated() { + return this.lastUpdated; + } + + public void setLastUpdated(final @Nullable Instant lastUpdated) { + this.lastUpdated = lastUpdated; + } +} diff --git a/src/main/java/io/papermc/patchroulette/repository/PatchRepository.java b/src/main/java/io/papermc/patchroulette/repository/PatchRepository.java index 2ee8ac8..1480c32 100644 --- a/src/main/java/io/papermc/patchroulette/repository/PatchRepository.java +++ b/src/main/java/io/papermc/patchroulette/repository/PatchRepository.java @@ -1,8 +1,7 @@ package io.papermc.patchroulette.repository; -import io.papermc.patchroulette.model.Patch; import io.papermc.patchroulette.model.PatchId; -import io.papermc.patchroulette.model.Status; +import io.papermc.patchroulette.model.StateType; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -10,18 +9,19 @@ import org.springframework.transaction.annotation.Transactional; @Repository -public interface PatchRepository extends JpaRepository { +public interface PatchRepository extends JpaRepository { @Transactional(readOnly = true) - List getPatchesByStatusAndMinecraftVersion(Status status, String minecraftVersion); + List getPatchesByStateTypeAndMinecraftVersion( + StateType stateType, String minecraftVersion); @Transactional(readOnly = true) - List getPatchesByMinecraftVersion(String minecraftVersion); + List getPatchesByMinecraftVersion(String minecraftVersion); @Transactional void deleteAllByMinecraftVersion(String minecraftVersion); @Transactional(readOnly = true) - @Query("SELECT p.minecraftVersion FROM Patch p GROUP BY p.minecraftVersion ORDER BY" + @Query("SELECT p.minecraftVersion FROM PatchEntity p GROUP BY p.minecraftVersion ORDER BY" + " MAX(p.lastUpdated)") List getMinecraftVersions(); } diff --git a/src/main/java/io/papermc/patchroulette/service/PatchService.java b/src/main/java/io/papermc/patchroulette/service/PatchService.java index 5dd52b5..ed2b23f 100644 --- a/src/main/java/io/papermc/patchroulette/service/PatchService.java +++ b/src/main/java/io/papermc/patchroulette/service/PatchService.java @@ -2,12 +2,16 @@ import io.papermc.patchroulette.model.Patch; import io.papermc.patchroulette.model.PatchId; -import io.papermc.patchroulette.model.Status; +import io.papermc.patchroulette.model.PatchState; +import io.papermc.patchroulette.model.StateType; +import io.papermc.patchroulette.repository.PatchCodec; +import io.papermc.patchroulette.repository.PatchEntity; import io.papermc.patchroulette.repository.PatchRepository; +import jakarta.persistence.EntityNotFoundException; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -22,29 +26,33 @@ public PatchService(final PatchRepository patchRepository) { this.patchRepository = patchRepository; } + private Patch loadPatch(final PatchId patchId) { + return PatchCodec.toDomain( + this.patchRepository.findById(patchId).orElseThrow(EntityNotFoundException::new)); + } + @Transactional public void setPatches(final String minecraftVersion, final List paths) { - final List patches = paths.stream() - .map(path -> { - final Patch patch = new Patch(); - patch.setPath(path); - patch.setStatus(Status.AVAILABLE); - patch.setMinecraftVersion(minecraftVersion); - patch.setLastUpdated(Instant.now()); - return patch; - }) + final List entities = paths.stream() + .map(path -> PatchCodec.toEntity(new Patch( + new PatchId(minecraftVersion, path), new PatchState.Available(), Instant.now()))) .toList(); - this.patchRepository.saveAll(patches); + this.patchRepository.saveAll(entities); } public List getAvailablePatches(final String minecraftVersion) { - return this.patchRepository.getPatchesByStatusAndMinecraftVersion( - Status.AVAILABLE, minecraftVersion); + return this.patchRepository + .getPatchesByStateTypeAndMinecraftVersion(StateType.AVAILABLE, minecraftVersion) + .stream() + .map(PatchCodec::toDomain) + .toList(); } public List getAllPatches(final String minecraftVersion) { - return this.patchRepository.getPatchesByMinecraftVersion(minecraftVersion); + return this.patchRepository.getPatchesByMinecraftVersion(minecraftVersion).stream() + .map(PatchCodec::toDomain) + .toList(); } @Transactional @@ -53,17 +61,13 @@ public List startWorkOnPatches( final List startedPatches = new ArrayList<>(); for (final String path : patches) { final PatchId patchId = new PatchId(minecraftVersion, path); - final Patch patch = this.patchRepository.getReferenceById(patchId); - if (patch.getStatus() != Status.AVAILABLE) { + final Patch patch = this.loadPatch(patchId); + if (!(patch.state() instanceof PatchState.Available)) { continue; } - if (patch.getResponsibleUser() != null) { - continue; - } - patch.setStatus(Status.WIP); - patch.setResponsibleUser(user); - patch.setLastUpdated(Instant.now()); - this.patchRepository.save(patch); + final Patch started = + new Patch(patchId, new PatchState.InProgress(user, Instant.now()), Instant.now()); + this.patchRepository.save(PatchCodec.toEntity(started)); startedPatches.add(path); } return startedPatches; @@ -71,44 +75,46 @@ public List startWorkOnPatches( @Transactional public void cancelWorkOnPatch(final PatchId patchId) { - final Patch patch = this.patchRepository.getReferenceById(patchId); - if (patch.getStatus() != Status.WIP && patch.getStatus() != Status.DONE) { + final Patch patch = this.loadPatch(patchId); + if (!(patch.state() instanceof PatchState.InProgress) + && !(patch.state() instanceof PatchState.Completed)) { throw new IllegalStateException("Patch " + patchId + " is not WIP"); } - patch.setStatus(Status.AVAILABLE); - patch.setResponsibleUser(null); - patch.setDuration(null); - patch.setLastUpdated(Instant.now()); - this.patchRepository.save(patch); + final Patch cancelled = new Patch(patchId, new PatchState.Available(), Instant.now()); + this.patchRepository.save(PatchCodec.toEntity(cancelled)); } @Transactional public void finishWorkOnPatch(final PatchId patchId, final String user) { - final Patch patch = this.patchRepository.getReferenceById(patchId); - if (patch.getStatus() != Status.WIP) { - throw new IllegalStateException("Patch " + patchId + " is not WIP"); - } - final String responsibleUser = Objects.requireNonNull( - patch.getResponsibleUser(), "Patch " + patchId + " has no responsible user"); - if (!responsibleUser.equals(user)) { - throw new IllegalStateException("User " + user + " is not responsible for patch " + patchId); - } - patch.setStatus(Status.DONE); - patch.updateDuration(); - patch.setLastUpdated(Instant.now()); - this.patchRepository.save(patch); + final Patch patch = this.loadPatch(patchId); + final Patch finished = new Patch( + patchId, + switch (patch.state()) { + case PatchState.Available a -> + throw new IllegalStateException("Patch " + patchId + " is not WIP"); + case PatchState.InProgress w -> { + if (!w.responsibleUser().equals(user)) { + throw new IllegalStateException( + "User " + user + " is not responsible for patch " + patchId); + } + yield new PatchState.Completed(user, Duration.between(w.startedAt(), Instant.now())); + } + case PatchState.Completed c -> + throw new IllegalStateException("Patch " + patchId + " is not WIP"); + }, + Instant.now()); + this.patchRepository.save(PatchCodec.toEntity(finished)); } @Transactional public void undoPatch(final PatchId patchId, final String user) { - final Patch patch = this.patchRepository.getReferenceById(patchId); - if (patch.getStatus() != Status.DONE) { + final Patch patch = this.loadPatch(patchId); + if (!(patch.state() instanceof PatchState.Completed)) { throw new IllegalStateException("Patch " + patchId + " is not DONE"); } - patch.setStatus(Status.WIP); - patch.setResponsibleUser(user); - patch.setLastUpdated(Instant.now()); - this.patchRepository.save(patch); + final Patch undone = + new Patch(patchId, new PatchState.InProgress(user, Instant.now()), Instant.now()); + this.patchRepository.save(PatchCodec.toEntity(undone)); } public void clearPatches(final String minecraftVersion) { diff --git a/src/main/resources/db/migration/h2/V3__sealed_state.sql b/src/main/resources/db/migration/h2/V3__sealed_state.sql new file mode 100644 index 0000000..f6eca53 --- /dev/null +++ b/src/main/resources/db/migration/h2/V3__sealed_state.sql @@ -0,0 +1,16 @@ +ALTER TABLE patch ADD COLUMN state_type VARCHAR(16); +ALTER TABLE patch ADD COLUMN started_at TIMESTAMP(6) WITH TIME ZONE; + +UPDATE patch +SET state_type = CASE status + WHEN 0 THEN 'AVAILABLE' + WHEN 1 THEN 'WIP' + WHEN 2 THEN 'DONE' + ELSE 'AVAILABLE' +END; + +-- Work start time was recorded in last_updated when work began. +UPDATE patch SET started_at = last_updated WHERE status = 1; + +ALTER TABLE patch ALTER COLUMN state_type SET NOT NULL; +ALTER TABLE patch DROP COLUMN status; diff --git a/src/main/resources/db/migration/postgresql/V3__sealed_state.sql b/src/main/resources/db/migration/postgresql/V3__sealed_state.sql new file mode 100644 index 0000000..f6eca53 --- /dev/null +++ b/src/main/resources/db/migration/postgresql/V3__sealed_state.sql @@ -0,0 +1,16 @@ +ALTER TABLE patch ADD COLUMN state_type VARCHAR(16); +ALTER TABLE patch ADD COLUMN started_at TIMESTAMP(6) WITH TIME ZONE; + +UPDATE patch +SET state_type = CASE status + WHEN 0 THEN 'AVAILABLE' + WHEN 1 THEN 'WIP' + WHEN 2 THEN 'DONE' + ELSE 'AVAILABLE' +END; + +-- Work start time was recorded in last_updated when work began. +UPDATE patch SET started_at = last_updated WHERE status = 1; + +ALTER TABLE patch ALTER COLUMN state_type SET NOT NULL; +ALTER TABLE patch DROP COLUMN status;