From f5b0b64fc48e964ccdb9393e07a9e218b9f2ef81 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 28 May 2026 05:43:45 +0900 Subject: [PATCH] feat(tenants): TenantStatus enum + status endpoint + optional mode Closes the fourth contract gap with the admin UI. The Tenants page needs three-state lifecycle (ACTIVE / SUSPENDED / ARCHIVED) and a single setStatus endpoint, not the boolean active flag plus two separate /activate /deactivate routes; it also doesn't send `mode` when provisioning a tenant. Status enum ----------- - New TenantStatus enum in tenant-api: ACTIVE / SUSPENDED / ARCHIVED. Vocabulary matches the UI Tag set so wire shape and presentation line up without translation. Schema (V10) ------------ - Three-step online-safe rewrite of platform_tenant: 1. add nullable status VARCHAR(16), 2. back-fill from active (true -> ACTIVE, false -> SUSPENDED), 3. swap NOT NULL + indexes and drop active. - New idx_platform_tenant_status replaces the old idx_..._active. Entity / metadata ----------------- - PlatformTenantEntity: active boolean -> status TenantStatus (@Enumerated STRING). All-args constructor updated. - TenantMetadata: active boolean -> status TenantStatus, with null-check in the canonical constructor. Service ------- - TenantService gains setStatus(id, status) as the primary lifecycle method. - activate(id) / deactivate(id) demoted to default methods that dispatch through setStatus(ACTIVE) / setStatus(SUSPENDED). All prior callers keep working. - DefaultTenantService.create now persists status = ACTIVE on new tenants. Controller ---------- - POST /tenants: mode is now optional (defaults to TenantMode.SINGLE when the UI doesn't send it). - PUT /tenants/{id}/status (new): the admin UI's single setter; body is {"status": "ACTIVE" | "SUSPENDED" | "ARCHIVED"}. - PUT /tenants/{id}/activate and /deactivate kept as backward- compatible shortcuts that route through setStatus. --- .../kit/admin/tenant/CreateTenantRequest.java | 11 ++++++-- .../admin/tenant/TenantAdminController.java | 18 ++++++++++++- .../tenant/UpdateTenantStatusRequest.java | 10 ++++++++ .../kr/devslab/kit/tenant/TenantMetadata.java | 3 ++- .../kr/devslab/kit/tenant/TenantService.java | 18 ++++++++++--- .../kr/devslab/kit/tenant/TenantStatus.java | 25 +++++++++++++++++++ .../core/entity/PlatformTenantEntity.java | 10 +++++--- .../core/service/DefaultTenantService.java | 20 ++++++--------- .../migration/V10__platform_tenant_status.sql | 25 +++++++++++++++++++ 9 files changed, 117 insertions(+), 23 deletions(-) create mode 100644 devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/UpdateTenantStatusRequest.java create mode 100644 devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantStatus.java create mode 100644 devslab-kit-tenant-core/src/main/resources/db/migration/V10__platform_tenant_status.sql diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/CreateTenantRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/CreateTenantRequest.java index 28d44e1..df05530 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/CreateTenantRequest.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/CreateTenantRequest.java @@ -1,13 +1,20 @@ package kr.devslab.kit.admin.tenant; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import kr.devslab.kit.tenant.TenantMode; +/** + * Wire shape for {@code POST /admin/api/v1/tenants}. + * + *

The admin UI sends just {@code id} + {@code name} when provisioning + * a tenant, so {@code mode} is optional here. When omitted, the controller + * uses {@link TenantMode#SINGLE} as the default — the right value for + * single-application tenant deployments. + */ public record CreateTenantRequest( @NotBlank @Size(max = 64) String id, @NotBlank @Size(max = 128) String name, - @NotNull TenantMode mode + TenantMode mode ) { } diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/TenantAdminController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/TenantAdminController.java index 6961167..ac3bc62 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/TenantAdminController.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/TenantAdminController.java @@ -5,6 +5,7 @@ import kr.devslab.kit.admin.AdminApiPaths; import kr.devslab.kit.core.id.TenantId; import kr.devslab.kit.tenant.TenantMetadata; +import kr.devslab.kit.tenant.TenantMode; import kr.devslab.kit.tenant.TenantService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -28,7 +29,8 @@ public TenantAdminController(TenantService tenantService) { @PostMapping public ResponseEntity create(@Valid @RequestBody CreateTenantRequest req) { - TenantMetadata created = tenantService.create(TenantId.of(req.id()), req.name(), req.mode()); + TenantMode mode = req.mode() == null ? TenantMode.SINGLE : req.mode(); + TenantMetadata created = tenantService.create(TenantId.of(req.id()), req.name(), mode); return ResponseEntity.status(201).body(created); } @@ -50,6 +52,20 @@ public ResponseEntity rename(@PathVariable String id, @Valid @RequestBody return ResponseEntity.noContent().build(); } + /** + * Generic lifecycle setter used by the admin UI. The + * {@code /activate} / {@code /deactivate} shorthands below + * dispatch through {@link TenantService} default methods. + */ + @PutMapping("/{id}/status") + public ResponseEntity updateStatus( + @PathVariable String id, + @Valid @RequestBody UpdateTenantStatusRequest req + ) { + tenantService.setStatus(TenantId.of(id), req.status()); + return ResponseEntity.noContent().build(); + } + @PutMapping("/{id}/activate") public ResponseEntity activate(@PathVariable String id) { tenantService.activate(TenantId.of(id)); diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/UpdateTenantStatusRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/UpdateTenantStatusRequest.java new file mode 100644 index 0000000..1302e24 --- /dev/null +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/tenant/UpdateTenantStatusRequest.java @@ -0,0 +1,10 @@ +package kr.devslab.kit.admin.tenant; + +import jakarta.validation.constraints.NotNull; +import kr.devslab.kit.tenant.TenantStatus; + +/** + * Wire shape for {@code PUT /admin/api/v1/tenants/{id}/status}. + */ +public record UpdateTenantStatusRequest(@NotNull TenantStatus status) { +} diff --git a/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantMetadata.java b/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantMetadata.java index 7dda9c4..ea781f7 100644 --- a/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantMetadata.java +++ b/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantMetadata.java @@ -8,13 +8,14 @@ public record TenantMetadata( TenantId id, String name, TenantMode mode, - boolean active, + TenantStatus status, Instant createdAt ) { public TenantMetadata { Objects.requireNonNull(id, "TenantMetadata id must not be null"); Objects.requireNonNull(mode, "TenantMetadata mode must not be null"); + Objects.requireNonNull(status, "TenantMetadata status must not be null"); Objects.requireNonNull(createdAt, "TenantMetadata createdAt must not be null"); if (name == null || name.isBlank()) { throw new IllegalArgumentException("TenantMetadata name must not be null or blank"); diff --git a/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantService.java b/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantService.java index a548992..92df48e 100644 --- a/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantService.java +++ b/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantService.java @@ -10,9 +10,21 @@ public interface TenantService { void rename(TenantId id, String newName); - void deactivate(TenantId id); - - void activate(TenantId id); + /** + * Move a tenant to the given lifecycle state. The activate / deactivate + * shorthands below are kept for callers that don't need the full enum. + */ + void setStatus(TenantId id, TenantStatus status); + + /** Convenience for {@code setStatus(id, TenantStatus.SUSPENDED)}. */ + default void deactivate(TenantId id) { + setStatus(id, TenantStatus.SUSPENDED); + } + + /** Convenience for {@code setStatus(id, TenantStatus.ACTIVE)}. */ + default void activate(TenantId id) { + setStatus(id, TenantStatus.ACTIVE); + } void delete(TenantId id); diff --git a/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantStatus.java b/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantStatus.java new file mode 100644 index 0000000..da4dcd7 --- /dev/null +++ b/devslab-kit-tenant-api/src/main/java/kr/devslab/kit/tenant/TenantStatus.java @@ -0,0 +1,25 @@ +package kr.devslab.kit.tenant; + +/** + * Lifecycle state of a {@link TenantMetadata}. + * + *

Three states: + * + *

+ * + *

The vocabulary lines up with the admin UI's tenant page so the wire + * shape and the UI tag set match without translation. + */ +public enum TenantStatus { + ACTIVE, + SUSPENDED, + ARCHIVED +} diff --git a/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/entity/PlatformTenantEntity.java b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/entity/PlatformTenantEntity.java index e6bc118..d1bcf92 100644 --- a/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/entity/PlatformTenantEntity.java +++ b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/entity/PlatformTenantEntity.java @@ -8,6 +8,7 @@ import jakarta.persistence.Table; import java.time.Instant; import kr.devslab.kit.tenant.TenantMode; +import kr.devslab.kit.tenant.TenantStatus; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -31,17 +32,18 @@ public class PlatformTenantEntity { @Column(name = "mode", nullable = false, length = 16) private TenantMode mode; - @Column(name = "active", nullable = false) - private boolean active; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private TenantStatus status; @Column(name = "created_at", nullable = false) private Instant createdAt; - public PlatformTenantEntity(String id, String name, TenantMode mode, boolean active, Instant createdAt) { + public PlatformTenantEntity(String id, String name, TenantMode mode, TenantStatus status, Instant createdAt) { this.id = id; this.name = name; this.mode = mode; - this.active = active; + this.status = status; this.createdAt = createdAt; } } diff --git a/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/service/DefaultTenantService.java b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/service/DefaultTenantService.java index 0c71093..ffd8695 100644 --- a/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/service/DefaultTenantService.java +++ b/devslab-kit-tenant-core/src/main/java/kr/devslab/kit/tenant/core/service/DefaultTenantService.java @@ -8,6 +8,7 @@ import kr.devslab.kit.tenant.TenantMetadata; import kr.devslab.kit.tenant.TenantMode; import kr.devslab.kit.tenant.TenantService; +import kr.devslab.kit.tenant.TenantStatus; import kr.devslab.kit.tenant.core.entity.PlatformTenantEntity; import kr.devslab.kit.tenant.core.repository.JpaPlatformTenantRepository; import org.springframework.transaction.annotation.Transactional; @@ -29,7 +30,7 @@ public TenantMetadata create(TenantId id, String name, TenantMode mode) { throw new IllegalStateException("Tenant already exists: " + id); } PlatformTenantEntity entity = new PlatformTenantEntity( - id.value(), name, mode, true, Instant.now(clock)); + id.value(), name, mode, TenantStatus.ACTIVE, Instant.now(clock)); repository.save(entity); return toMetadata(entity); } @@ -44,18 +45,13 @@ public void rename(TenantId id, String newName) { @Override @Transactional - public void deactivate(TenantId id) { - PlatformTenantEntity entity = repository.findById(id.value()) - .orElseThrow(() -> new IllegalArgumentException("Tenant not found: " + id)); - entity.setActive(false); - } - - @Override - @Transactional - public void activate(TenantId id) { + public void setStatus(TenantId id, TenantStatus status) { + if (status == null) { + throw new IllegalArgumentException("status must not be null"); + } PlatformTenantEntity entity = repository.findById(id.value()) .orElseThrow(() -> new IllegalArgumentException("Tenant not found: " + id)); - entity.setActive(true); + entity.setStatus(status); } @Override @@ -81,7 +77,7 @@ private TenantMetadata toMetadata(PlatformTenantEntity e) { TenantId.of(e.getId()), e.getName(), e.getMode(), - e.isActive(), + e.getStatus(), e.getCreatedAt() ); } diff --git a/devslab-kit-tenant-core/src/main/resources/db/migration/V10__platform_tenant_status.sql b/devslab-kit-tenant-core/src/main/resources/db/migration/V10__platform_tenant_status.sql new file mode 100644 index 0000000..87741e6 --- /dev/null +++ b/devslab-kit-tenant-core/src/main/resources/db/migration/V10__platform_tenant_status.sql @@ -0,0 +1,25 @@ +-- Replace the boolean `active` flag with a richer TenantStatus enum +-- (ACTIVE / SUSPENDED / ARCHIVED). Existing rows are back-filled in place: +-- active = true -> ACTIVE +-- active = false -> SUSPENDED (treat as "paused", not as "soft-deleted") +-- +-- Done in three steps so the rewrite stays online-safe: +-- 1. add nullable status column, +-- 2. back-fill from active, +-- 3. swap constraints + indexes and drop active. + +ALTER TABLE platform_tenant + ADD COLUMN status VARCHAR(16); + +UPDATE platform_tenant + SET status = CASE WHEN active THEN 'ACTIVE' ELSE 'SUSPENDED' END + WHERE status IS NULL; + +ALTER TABLE platform_tenant + ALTER COLUMN status SET NOT NULL; + +DROP INDEX IF EXISTS idx_platform_tenant_active; +CREATE INDEX IF NOT EXISTS idx_platform_tenant_status ON platform_tenant(status); + +ALTER TABLE platform_tenant + DROP COLUMN active;