From b6a8c5c1ccda8b5b0122e1a10930eac617268869 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Thu, 28 May 2026 05:30:25 +0900 Subject: [PATCH] feat(menus): tree endpoint, get-by-id, icon column, admin-ui field names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin UI's Menus page calls three things this controller didn't expose, plus a column the entity didn't have: - GET /admin/api/v1/menus/tree?tenantId=... → nested tree (TreeTable) - GET /admin/api/v1/menus/{id} → single item - POST/PUT body fields displayOrder / requiredPermission / icon (not sortOrder / requiredPermissionCode / —) - MenuItem.icon for sidebar rendering (e.g. "pi-users") This PR closes that gap while keeping the entity column names stable, so nothing downstream of MenuAdminService breaks. Schema ------ - V8__platform_menu_icon.sql adds `icon VARCHAR(64)` to platform_menu, nullable. Existing rows unaffected; the column is text-only on the UI when null. Entity / API ------------ - PlatformMenuEntity: new icon field + extended all-args constructor. - MenuItem: new icon field, propagated by MenuTreeBuilder and PermissionBasedMenuFilter so visibility-filtered trees keep icons. - MenuAdminService.create / update: new icon parameter; partial- update semantics on update (icon updates only if non-null). - MenuAdminService.findById: new read used by GET /menus/{id}. DTOs ---- - CreateMenuRequest / UpdateMenuRequest: rename to the wire shape the admin UI sends — displayOrder (was sortOrder), requiredPermission (was requiredPermissionCode), plus icon. Validation constraints preserved. - MenuResponse: two factory methods. `flat(entity)` for the existing list + new get-by-id endpoint (children == null). `withChildren` for the new tree endpoint (children == nested list). Wire field names also follow the UI: displayOrder, requiredPermission, icon. Controller ---------- - New GET /menus/tree builds the nested response in-controller from the same JPA repository the flat list uses (single round-trip, sorted by displayOrder at every level). - New GET /menus/{id} returns 404 on miss. - Existing POST/PUT/GET/DELETE adjusted to the renamed DTO fields. Other call-sites ---------------- - DiagnosticsController.filterByCodes preserves the icon when pruning the menu tree by user permissions. - MenuTreeBuilderTest fixture updated to the new entity constructor (icon = null). V8 migration comment intentionally avoids `${...}` because Flyway's PlaceholderReplacingReader scans SQL comments too — the first run hit `org.flywaydb.core.api.FlywayException` in sample-app tests because the original comment contained `pi pi-${icon}`. --- .../diagnostics/DiagnosticsController.java | 2 +- .../kit/admin/menu/CreateMenuRequest.java | 14 ++++- .../kit/admin/menu/MenuAdminController.java | 56 +++++++++++++++++-- .../devslab/kit/admin/menu/MenuResponse.java | 44 +++++++++++++-- .../kit/admin/menu/UpdateMenuRequest.java | 11 +++- .../java/kr/devslab/kit/menu/MenuItem.java | 1 + .../menu/core/entity/PlatformMenuEntity.java | 5 ++ .../menu/core/service/MenuAdminService.java | 19 ++++++- .../menu/core/service/MenuTreeBuilder.java | 1 + .../service/PermissionBasedMenuFilter.java | 1 + .../db/migration/V8__platform_menu_icon.sql | 9 +++ .../core/service/MenuTreeBuilderTest.java | 1 + 12 files changed, 148 insertions(+), 16 deletions(-) create mode 100644 devslab-kit-menu-core/src/main/resources/db/migration/V8__platform_menu_icon.sql diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/diagnostics/DiagnosticsController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/diagnostics/DiagnosticsController.java index 6e3649d..27ab20c 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/diagnostics/DiagnosticsController.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/diagnostics/DiagnosticsController.java @@ -92,7 +92,7 @@ private kr.devslab.kit.menu.MenuItem filterByCodes(kr.devslab.kit.menu.MenuItem .filter(java.util.Objects::nonNull) .toList(); return new kr.devslab.kit.menu.MenuItem( - item.id(), item.code(), item.label(), item.path(), + item.id(), item.code(), item.label(), item.path(), item.icon(), item.requiredPermission(), visibleChildren); } diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/CreateMenuRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/CreateMenuRequest.java index 945131a..8542f4a 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/CreateMenuRequest.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/CreateMenuRequest.java @@ -4,13 +4,23 @@ import jakarta.validation.constraints.Size; import java.util.UUID; +/** + * Wire shape for {@code POST /admin/api/v1/menus}. + * + *

Field names mirror what {@code devslab-kit-admin-ui} sends: + * {@code displayOrder} (not {@code sortOrder}), + * {@code requiredPermission} (not {@code requiredPermissionCode}), + * plus the new {@code icon} token (e.g. {@code "pi-users"}). The + * entity column names underneath are unchanged. + */ public record CreateMenuRequest( @NotBlank @Size(max = 64) String tenantId, @NotBlank @Size(max = 64) String code, @NotBlank @Size(max = 255) String label, @Size(max = 255) String path, UUID parentId, - int sortOrder, - @Size(max = 128) String requiredPermissionCode + int displayOrder, + @Size(max = 128) String requiredPermission, + @Size(max = 64) String icon ) { } diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuAdminController.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuAdminController.java index 2a24d4c..bd8ccee 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuAdminController.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuAdminController.java @@ -1,11 +1,15 @@ package kr.devslab.kit.admin.menu; import jakarta.validation.Valid; +import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import kr.devslab.kit.admin.AdminApiPaths; import kr.devslab.kit.core.id.MenuId; import kr.devslab.kit.core.id.TenantId; +import kr.devslab.kit.menu.core.entity.PlatformMenuEntity; import kr.devslab.kit.menu.core.service.MenuAdminService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -36,20 +40,62 @@ public ResponseEntity create(@Valid @RequestBody CreateMenuRequest req.label(), req.path(), req.parentId() == null ? null : MenuId.of(req.parentId()), - req.sortOrder(), - req.requiredPermissionCode() + req.displayOrder(), + req.requiredPermission(), + req.icon() ); - return ResponseEntity.status(201).body(MenuResponse.from(entity)); + return ResponseEntity.status(201).body(MenuResponse.flat(entity)); } @GetMapping public List list(@RequestParam String tenantId) { - return service.listByTenant(TenantId.of(tenantId)).stream().map(MenuResponse::from).toList(); + return service.listByTenant(TenantId.of(tenantId)).stream().map(MenuResponse::flat).toList(); + } + + @GetMapping("/{id}") + public ResponseEntity get(@PathVariable UUID id) { + return service.findById(MenuId.of(id)) + .map(MenuResponse::flat) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + /** + * Tree view for the admin UI's TreeTable component. Returns root nodes only; + * each root carries its descendants nested under {@code children}, sorted by + * {@code displayOrder}. + */ + @GetMapping("/tree") + public List tree(@RequestParam String tenantId) { + List entities = service.listByTenant(TenantId.of(tenantId)); + Map> byParent = entities.stream() + .filter(e -> e.getParentId() != null) + .collect(Collectors.groupingBy(PlatformMenuEntity::getParentId)); + return entities.stream() + .filter(e -> e.getParentId() == null) + .sorted(Comparator.comparingInt(PlatformMenuEntity::getSortOrder)) + .map(root -> toNode(root, byParent)) + .toList(); + } + + private MenuResponse toNode(PlatformMenuEntity entity, Map> byParent) { + List children = byParent.getOrDefault(entity.getId(), List.of()).stream() + .sorted(Comparator.comparingInt(PlatformMenuEntity::getSortOrder)) + .map(child -> toNode(child, byParent)) + .toList(); + return MenuResponse.withChildren(entity, children); } @PutMapping("/{id}") public ResponseEntity update(@PathVariable UUID id, @Valid @RequestBody UpdateMenuRequest req) { - service.update(MenuId.of(id), req.label(), req.path(), req.sortOrder(), req.requiredPermissionCode()); + service.update( + MenuId.of(id), + req.label(), + req.path(), + req.displayOrder(), + req.requiredPermission(), + req.icon() + ); return ResponseEntity.noContent().build(); } diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuResponse.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuResponse.java index a61b5b2..005c883 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuResponse.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/MenuResponse.java @@ -1,8 +1,25 @@ package kr.devslab.kit.admin.menu; +import java.util.List; import java.util.UUID; import kr.devslab.kit.menu.core.entity.PlatformMenuEntity; +/** + * Wire shape for menu items returned by {@code /admin/api/v1/menus*}. + * + *

Two representations live here: + * + *

+ * + *

Field names mirror the admin UI's {@code MenuItem} interface: + * {@code displayOrder} (not the entity's {@code sortOrder}) and + * {@code requiredPermission} (not {@code requiredPermissionCode}). + */ public record MenuResponse( UUID id, String tenantId, @@ -10,11 +27,13 @@ public record MenuResponse( String label, String path, UUID parentId, - int sortOrder, - String requiredPermissionCode + int displayOrder, + String requiredPermission, + String icon, + List children ) { - public static MenuResponse from(PlatformMenuEntity entity) { + public static MenuResponse flat(PlatformMenuEntity entity) { return new MenuResponse( entity.getId(), entity.getTenantId(), @@ -23,7 +42,24 @@ public static MenuResponse from(PlatformMenuEntity entity) { entity.getPath(), entity.getParentId(), entity.getSortOrder(), - entity.getRequiredPermissionCode() + entity.getRequiredPermissionCode(), + entity.getIcon(), + null + ); + } + + public static MenuResponse withChildren(PlatformMenuEntity entity, List children) { + return new MenuResponse( + entity.getId(), + entity.getTenantId(), + entity.getCode(), + entity.getLabel(), + entity.getPath(), + entity.getParentId(), + entity.getSortOrder(), + entity.getRequiredPermissionCode(), + entity.getIcon(), + children == null ? List.of() : List.copyOf(children) ); } } diff --git a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/UpdateMenuRequest.java b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/UpdateMenuRequest.java index e2b3fd3..2bad226 100644 --- a/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/UpdateMenuRequest.java +++ b/devslab-kit-admin-api/src/main/java/kr/devslab/kit/admin/menu/UpdateMenuRequest.java @@ -2,10 +2,17 @@ import jakarta.validation.constraints.Size; +/** + * Wire shape for {@code PUT /admin/api/v1/menus/{id}}. + * + *

Every field is optional — only those passed in are applied. + * Field names match {@link CreateMenuRequest} (and the admin UI). + */ public record UpdateMenuRequest( @Size(max = 255) String label, @Size(max = 255) String path, - Integer sortOrder, - @Size(max = 128) String requiredPermissionCode + Integer displayOrder, + @Size(max = 128) String requiredPermission, + @Size(max = 64) String icon ) { } diff --git a/devslab-kit-menu-api/src/main/java/kr/devslab/kit/menu/MenuItem.java b/devslab-kit-menu-api/src/main/java/kr/devslab/kit/menu/MenuItem.java index 431e881..40b1650 100644 --- a/devslab-kit-menu-api/src/main/java/kr/devslab/kit/menu/MenuItem.java +++ b/devslab-kit-menu-api/src/main/java/kr/devslab/kit/menu/MenuItem.java @@ -10,6 +10,7 @@ public record MenuItem( String code, String label, String path, + String icon, Optional requiredPermission, List children ) { diff --git a/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/entity/PlatformMenuEntity.java b/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/entity/PlatformMenuEntity.java index 0a79cf4..d86aec8 100644 --- a/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/entity/PlatformMenuEntity.java +++ b/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/entity/PlatformMenuEntity.java @@ -47,6 +47,9 @@ public class PlatformMenuEntity { @Column(name = "required_permission_code", length = 128) private String requiredPermissionCode; + @Column(name = "icon", length = 64) + private String icon; + @Column(name = "created_at", nullable = false) private Instant createdAt; @@ -59,6 +62,7 @@ public PlatformMenuEntity( UUID parentId, int sortOrder, String requiredPermissionCode, + String icon, Instant createdAt ) { this.id = id; @@ -69,6 +73,7 @@ public PlatformMenuEntity( this.parentId = parentId; this.sortOrder = sortOrder; this.requiredPermissionCode = requiredPermissionCode; + this.icon = icon; this.createdAt = createdAt; } } diff --git a/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/service/MenuAdminService.java b/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/service/MenuAdminService.java index 4ee5e62..0a711d0 100644 --- a/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/service/MenuAdminService.java +++ b/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/service/MenuAdminService.java @@ -28,7 +28,8 @@ public PlatformMenuEntity create( String path, MenuId parentId, int sortOrder, - String requiredPermissionCode + String requiredPermissionCode, + String icon ) { PlatformMenuEntity entity = new PlatformMenuEntity( UUID.randomUUID(), @@ -39,6 +40,7 @@ public PlatformMenuEntity create( parentId == null ? null : parentId.value(), sortOrder, requiredPermissionCode, + icon, Instant.now(clock) ); repository.save(entity); @@ -46,13 +48,26 @@ public PlatformMenuEntity create( } @Transactional - public void update(MenuId id, String label, String path, Integer sortOrder, String requiredPermissionCode) { + public void update( + MenuId id, + String label, + String path, + Integer sortOrder, + String requiredPermissionCode, + String icon + ) { PlatformMenuEntity e = repository.findById(id.value()) .orElseThrow(() -> new IllegalArgumentException("Menu not found: " + id)); if (label != null) e.setLabel(label); if (path != null) e.setPath(path); if (sortOrder != null) e.setSortOrder(sortOrder); if (requiredPermissionCode != null) e.setRequiredPermissionCode(requiredPermissionCode); + if (icon != null) e.setIcon(icon); + } + + @Transactional(readOnly = true) + public java.util.Optional findById(MenuId id) { + return repository.findById(id.value()); } @Transactional diff --git a/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/service/MenuTreeBuilder.java b/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/service/MenuTreeBuilder.java index f694099..497e15f 100644 --- a/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/service/MenuTreeBuilder.java +++ b/devslab-kit-menu-core/src/main/java/kr/devslab/kit/menu/core/service/MenuTreeBuilder.java @@ -42,6 +42,7 @@ private MenuItem toItem(PlatformMenuEntity entity, Map