From b30311a5374b05b2dabd74c851677acdf83d1fbb Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl <15071461069@163.com> Date: Mon, 16 Mar 2026 14:27:40 +0800 Subject: [PATCH] feat: build governance center workflow --- scripts/governance-smoke-test.sh | 117 ++++++++ .../admin/AdminSkillReportController.java | 4 + .../portal/GovernanceController.java | 115 ++++++++ .../dto/AdminSkillReportActionRequest.java | 3 +- .../dto/GovernanceActivityItemResponse.java | 13 + .../dto/GovernanceInboxItemResponse.java | 12 + .../dto/GovernanceNotificationResponse.java | 14 + .../dto/GovernanceSummaryResponse.java | 8 + .../service/AdminAuditLogAppService.java | 25 +- .../GovernanceWorkbenchAppService.java | 236 ++++++++++++++++ .../V12__governance_notifications.sql | 15 ++ .../controller/GovernanceControllerTest.java | 159 +++++++++++ .../admin/AdminSkillReportControllerTest.java | 11 +- .../GovernanceWorkbenchAppServiceTest.java | 253 ++++++++++++++++++ .../GovernanceNotificationService.java | 50 ++++ .../domain/governance/UserNotification.java | 116 ++++++++ .../UserNotificationRepository.java | 10 + .../governance/UserNotificationStatus.java | 6 + .../domain/report/SkillReportDisposition.java | 7 + .../domain/report/SkillReportService.java | 41 ++- .../domain/review/PromotionService.java | 22 +- .../skillhub/domain/review/ReviewService.java | 22 +- .../skill/service/SkillGovernanceService.java | 22 +- .../GovernanceNotificationServiceTest.java | 85 ++++++ .../domain/report/SkillReportServiceTest.java | 64 ++++- .../domain/review/PromotionServiceTest.java | 50 +++- .../domain/review/ReviewServiceTest.java | 27 +- .../jpa/UserNotificationJpaRepository.java | 10 + web/src/api/client.ts | 45 +++- web/src/api/types.ts | 41 +++ web/src/app/router.tsx | 9 + .../governance/governance-activity.tsx | 39 +++ .../features/governance/governance-inbox.tsx | 69 +++++ .../governance/governance-notifications.tsx | 51 ++++ web/src/features/governance/use-governance.ts | 46 ++++ .../features/promotion/use-promotion-list.ts | 2 + web/src/features/report/use-skill-reports.ts | 6 +- web/src/features/review/use-review-detail.ts | 2 + web/src/i18n/locales/en.json | 46 ++++ web/src/i18n/locales/zh.json | 46 ++++ web/src/pages/admin/audit-log.tsx | 32 ++- web/src/pages/dashboard.tsx | 4 +- web/src/pages/dashboard/governance.tsx | 102 +++++++ web/src/pages/dashboard/reports.tsx | 66 ++++- web/src/shared/components/user-menu.tsx | 5 + web/src/shared/ui/tabs.tsx | 10 +- 46 files changed, 2101 insertions(+), 37 deletions(-) create mode 100755 scripts/governance-smoke-test.sh create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceActivityItemResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceNotificationResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceSummaryResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java create mode 100644 server/skillhub-app/src/main/resources/db/migration/V12__governance_notifications.sql create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppServiceTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationStatus.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportDisposition.java create mode 100644 server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java create mode 100644 web/src/features/governance/governance-activity.tsx create mode 100644 web/src/features/governance/governance-inbox.tsx create mode 100644 web/src/features/governance/governance-notifications.tsx create mode 100644 web/src/features/governance/use-governance.ts create mode 100644 web/src/pages/dashboard/governance.tsx diff --git a/scripts/governance-smoke-test.sh b/scripts/governance-smoke-test.sh new file mode 100755 index 00000000..2e8c0fb3 --- /dev/null +++ b/scripts/governance-smoke-test.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BASE_URL="${1:-http://localhost:8080}" +PASS=0 +FAIL=0 +COOKIE_FILE="$(mktemp)" + +cleanup() { + rm -f "$COOKIE_FILE" +} + +trap cleanup EXIT + +pass() { + echo "PASS: $1" + PASS=$((PASS + 1)) +} + +fail() { + echo "FAIL: $1" + FAIL=$((FAIL + 1)) +} + +json_field() { + local json="$1" + local expr="$2" + JSON_INPUT="$json" python3 - "$expr" <<'PY' +import json +import os +import sys + +expr = sys.argv[1] +value = json.loads(os.environ["JSON_INPUT"]) +for part in expr.split('.'): + if not part: + continue + if part.isdigit(): + value = value[int(part)] + else: + value = value[part] +if isinstance(value, (dict, list)): + print(json.dumps(value, ensure_ascii=False)) +else: + print(value) +PY +} + +assert_code() { + local description="$1" + local json="$2" + local expected="$3" + local actual + actual="$(json_field "$json" "code")" + if [[ "$actual" == "$expected" ]]; then + pass "$description" + else + fail "$description (expected code $expected, got $actual)" + fi +} + +assert_json_expr() { + local description="$1" + local json="$2" + local script="$3" + if JSON_INPUT="$json" python3 - </dev/null + +ADMIN_HEADERS=(-H "X-Mock-User-Id: local-admin" -b "$COOKIE_FILE" -c "$COOKIE_FILE") + +SUMMARY_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/summary")" +assert_code "Governance summary endpoint is available" "$SUMMARY_RESPONSE" "0" +assert_json_expr "Governance summary exposes review/promotion/report counts" "$SUMMARY_RESPONSE" $'summary = data["data"]\nassert "pendingReviews" in summary\nassert "pendingPromotions" in summary\nassert "pendingReports" in summary' + +INBOX_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/inbox")" +assert_code "Governance inbox endpoint is available" "$INBOX_RESPONSE" "0" +assert_json_expr "Governance inbox returns paged items" "$INBOX_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["page"] == 0' + +ACTIVITY_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/activity")" +assert_code "Governance activity endpoint is available" "$ACTIVITY_RESPONSE" "0" +assert_json_expr "Governance activity returns paged items" "$ACTIVITY_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["page"] == 0' + +NOTIFICATIONS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/web/governance/notifications")" +assert_code "Governance notifications endpoint is available" "$NOTIFICATIONS_RESPONSE" "0" +assert_json_expr "Governance notifications returns a list" "$NOTIFICATIONS_RESPONSE" $'assert isinstance(data["data"], list)' + +REPORTS_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/v1/admin/skill-reports?status=PENDING&page=0&size=5")" +assert_code "Admin report list endpoint is available" "$REPORTS_RESPONSE" "0" +assert_json_expr "Admin report list returns page metadata" "$REPORTS_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["size"] == 5' + +AUDIT_RESPONSE="$(curl -sS "${ADMIN_HEADERS[@]}" "$BASE_URL/api/v1/admin/audit-logs?action=REVIEW_APPROVE&page=0&size=5")" +assert_code "Audit log endpoint is available for governance filters" "$AUDIT_RESPONSE" "0" +assert_json_expr "Audit log endpoint returns page metadata" "$AUDIT_RESPONSE" $'payload = data["data"]\nassert isinstance(payload["items"], list)\nassert payload["size"] == 5' + +echo +echo "Results: $PASS passed, $FAIL failed" +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java index 0bdb76fa..0442a543 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/admin/AdminSkillReportController.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.admin; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.report.SkillReportDisposition; import com.iflytek.skillhub.domain.report.SkillReportService; import com.iflytek.skillhub.dto.AdminSkillReportActionRequest; import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; @@ -54,6 +55,9 @@ public class AdminSkillReportController extends BaseApiController { var report = skillReportService.resolveReport( reportId, principal.userId(), + request != null && request.disposition() != null + ? SkillReportDisposition.valueOf(request.disposition().trim().toUpperCase()) + : SkillReportDisposition.RESOLVE_ONLY, request != null ? request.comment() : null, httpRequest.getRemoteAddr(), httpRequest.getHeader("User-Agent") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java new file mode 100644 index 00000000..e14e4ea7 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/GovernanceController.java @@ -0,0 +1,115 @@ +package com.iflytek.skillhub.controller.portal; + +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.controller.BaseApiController; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; +import com.iflytek.skillhub.domain.governance.UserNotification; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.dto.ApiResponse; +import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.GovernanceActivityItemResponse; +import com.iflytek.skillhub.dto.GovernanceInboxItemResponse; +import com.iflytek.skillhub.dto.GovernanceNotificationResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.GovernanceWorkbenchAppService; +import java.util.Map; +import java.util.Set; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping({"/api/v1/governance", "/api/web/governance"}) +public class GovernanceController extends BaseApiController { + + private final GovernanceWorkbenchAppService governanceWorkbenchAppService; + private final RbacService rbacService; + private final GovernanceNotificationService governanceNotificationService; + + public GovernanceController(GovernanceWorkbenchAppService governanceWorkbenchAppService, + RbacService rbacService, + GovernanceNotificationService governanceNotificationService, + ApiResponseFactory responseFactory) { + super(responseFactory); + this.governanceWorkbenchAppService = governanceWorkbenchAppService; + this.rbacService = rbacService; + this.governanceNotificationService = governanceNotificationService; + } + + @GetMapping("/summary") + public ApiResponse summary( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles) { + return ok( + "response.success.read", + governanceWorkbenchAppService.getSummary(userId, userNsRoles != null ? userNsRoles : Map.of(), roles(userId)) + ); + } + + @GetMapping("/inbox") + public ApiResponse> inbox( + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @RequestParam(required = false) String type, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ok( + "response.success.read", + governanceWorkbenchAppService.listInbox( + userId, + userNsRoles != null ? userNsRoles : Map.of(), + roles(userId), + type, + page, + size + ) + ); + } + + @GetMapping("/activity") + public ApiResponse> activity( + @RequestAttribute("userId") String userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ok("response.success.read", governanceWorkbenchAppService.listActivity(roles(userId), page, size)); + } + + @GetMapping("/notifications") + public ApiResponse> notifications( + @RequestAttribute("userId") String userId) { + return ok( + "response.success.read", + governanceNotificationService.listNotifications(userId).stream().map(this::toNotificationResponse).toList() + ); + } + + @PostMapping("/notifications/{id}/read") + public ApiResponse markNotificationRead( + @PathVariable Long id, + @RequestAttribute("userId") String userId) { + return ok("response.success.updated", toNotificationResponse(governanceNotificationService.markRead(id, userId))); + } + + private Set roles(String userId) { + return rbacService.getUserRoleCodes(userId); + } + + private GovernanceNotificationResponse toNotificationResponse(UserNotification notification) { + return new GovernanceNotificationResponse( + notification.getId(), + notification.getCategory(), + notification.getEntityType(), + notification.getEntityId(), + notification.getTitle(), + notification.getBodyJson(), + notification.getStatus().name(), + notification.getCreatedAt() != null ? notification.getCreatedAt().toString() : null, + notification.getReadAt() != null ? notification.getReadAt().toString() : null + ); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java index 92124df1..f2a85e39 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportActionRequest.java @@ -1,5 +1,6 @@ package com.iflytek.skillhub.dto; public record AdminSkillReportActionRequest( - String comment + String comment, + String disposition ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceActivityItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceActivityItemResponse.java new file mode 100644 index 00000000..7ed30115 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceActivityItemResponse.java @@ -0,0 +1,13 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceActivityItemResponse( + Long id, + String action, + String actorUserId, + String actorDisplayName, + String targetType, + String targetId, + String details, + String timestamp +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java new file mode 100644 index 00000000..c9298a5e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceInboxItemResponse.java @@ -0,0 +1,12 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceInboxItemResponse( + String type, + Long id, + String title, + String subtitle, + String timestamp, + String namespace, + String skillSlug +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceNotificationResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceNotificationResponse.java new file mode 100644 index 00000000..696e9340 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceNotificationResponse.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceNotificationResponse( + Long id, + String category, + String entityType, + Long entityId, + String title, + String bodyJson, + String status, + String createdAt, + String readAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceSummaryResponse.java new file mode 100644 index 00000000..5a32049d --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/GovernanceSummaryResponse.java @@ -0,0 +1,8 @@ +package com.iflytek.skillhub.dto; + +public record GovernanceSummaryResponse( + long pendingReviews, + long pendingPromotions, + long pendingReports +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java index b712ab83..b811e9d7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/AdminAuditLogAppService.java @@ -10,6 +10,7 @@ import org.springframework.util.StringUtils; import java.sql.Timestamp; import java.time.Instant; +import java.util.Collection; import java.util.List; @Service @@ -32,6 +33,20 @@ public class AdminAuditLogAppService { String resourceId, Instant startTime, Instant endTime) { + return listAuditLogsByActions(page, size, userId, action != null ? List.of(action) : null, requestId, ipAddress, resourceType, resourceId, startTime, endTime); + } + + @Transactional(readOnly = true) + public PageResponse listAuditLogsByActions(int page, + int size, + String userId, + Collection actions, + String requestId, + String ipAddress, + String resourceType, + String resourceId, + Instant startTime, + Instant endTime) { MapSqlParameterSource parameters = new MapSqlParameterSource() .addValue("limit", size) .addValue("offset", Math.max(page, 0) * size); @@ -39,7 +54,7 @@ public class AdminAuditLogAppService { String whereClause = buildWhereClause( parameters, userId, - action, + actions, requestId, ipAddress, resourceType, @@ -92,7 +107,7 @@ public class AdminAuditLogAppService { private String buildWhereClause(MapSqlParameterSource parameters, String userId, - String action, + Collection actions, String requestId, String ipAddress, String resourceType, @@ -104,9 +119,9 @@ public class AdminAuditLogAppService { clause.append(" AND al.actor_user_id = :userId"); parameters.addValue("userId", userId.trim()); } - if (StringUtils.hasText(action)) { - clause.append(" AND al.action = :action"); - parameters.addValue("action", action.trim()); + if (actions != null && !actions.isEmpty()) { + clause.append(" AND al.action IN (:actions)"); + parameters.addValue("actions", actions.stream().filter(StringUtils::hasText).map(String::trim).toList()); } if (StringUtils.hasText(requestId)) { clause.append(" AND al.request_id = :requestId"); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java new file mode 100644 index 00000000..f11983dc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppService.java @@ -0,0 +1,236 @@ +package com.iflytek.skillhub.service; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportRepository; +import com.iflytek.skillhub.domain.report.SkillReportStatus; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.dto.AuditLogItemResponse; +import com.iflytek.skillhub.dto.GovernanceActivityItemResponse; +import com.iflytek.skillhub.dto.GovernanceInboxItemResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +@Service +public class GovernanceWorkbenchAppService { + + private static final int SUMMARY_PAGE_SIZE = 100; + private static final Set ACTIVITY_ACTIONS = Set.of( + "REVIEW_SUBMIT", + "REVIEW_APPROVE", + "REVIEW_REJECT", + "REVIEW_WITHDRAW", + "PROMOTION_SUBMIT", + "PROMOTION_APPROVE", + "PROMOTION_REJECT", + "REPORT_SKILL", + "RESOLVE_SKILL_REPORT", + "DISMISS_SKILL_REPORT", + "HIDE_SKILL", + "ARCHIVE_SKILL", + "UNHIDE_SKILL", + "UNARCHIVE_SKILL" + ); + + private final ReviewTaskRepository reviewTaskRepository; + private final PromotionRequestRepository promotionRequestRepository; + private final SkillReportRepository skillReportRepository; + private final SkillRepository skillRepository; + private final SkillVersionRepository skillVersionRepository; + private final NamespaceRepository namespaceRepository; + private final AdminAuditLogAppService adminAuditLogAppService; + + public GovernanceWorkbenchAppService(ReviewTaskRepository reviewTaskRepository, + PromotionRequestRepository promotionRequestRepository, + SkillReportRepository skillReportRepository, + SkillRepository skillRepository, + SkillVersionRepository skillVersionRepository, + NamespaceRepository namespaceRepository, + AdminAuditLogAppService adminAuditLogAppService) { + this.reviewTaskRepository = reviewTaskRepository; + this.promotionRequestRepository = promotionRequestRepository; + this.skillReportRepository = skillReportRepository; + this.skillRepository = skillRepository; + this.skillVersionRepository = skillVersionRepository; + this.namespaceRepository = namespaceRepository; + this.adminAuditLogAppService = adminAuditLogAppService; + } + + public GovernanceSummaryResponse getSummary(String userId, + Map namespaceRoles, + Set platformRoles) { + return new GovernanceSummaryResponse( + visiblePendingReviews(namespaceRoles, platformRoles, SUMMARY_PAGE_SIZE).getTotalElements(), + hasPlatformGovernanceRole(platformRoles) + ? promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, SUMMARY_PAGE_SIZE)).getTotalElements() + : 0, + hasPlatformGovernanceRole(platformRoles) + ? skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, SUMMARY_PAGE_SIZE)).getTotalElements() + : 0 + ); + } + + public PageResponse listInbox(String userId, + Map namespaceRoles, + Set platformRoles, + String type, + int page, + int size) { + List items = new ArrayList<>(); + boolean includeAll = type == null || type.isBlank(); + if (includeAll || "REVIEW".equalsIgnoreCase(type)) { + visiblePendingReviews(namespaceRoles, platformRoles, size).getContent().stream() + .map(this::toReviewInboxItem) + .forEach(items::add); + } + if (hasPlatformGovernanceRole(platformRoles) && (includeAll || "PROMOTION".equalsIgnoreCase(type))) { + promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(page, size)).getContent().stream() + .map(this::toPromotionInboxItem) + .forEach(items::add); + } + if (hasPlatformGovernanceRole(platformRoles) && (includeAll || "REPORT".equalsIgnoreCase(type))) { + skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(page, size)).getContent().stream() + .map(this::toReportInboxItem) + .forEach(items::add); + } + items.sort(Comparator.comparing( + GovernanceInboxItemResponse::timestamp, + Comparator.nullsLast(String::compareTo) + ).reversed()); + int fromIndex = Math.min(page * size, items.size()); + int toIndex = Math.min(fromIndex + size, items.size()); + return new PageResponse<>(items.subList(fromIndex, toIndex), items.size(), page, size); + } + + public PageResponse listActivity(Set platformRoles, int page, int size) { + if (!canReadActivity(platformRoles)) { + return new PageResponse<>(List.of(), 0, page, size); + } + PageResponse raw = adminAuditLogAppService.listAuditLogsByActions( + page, + size, + null, + ACTIVITY_ACTIONS, + null, + null, + null, + null, + null, + null + ); + List items = raw.items().stream() + .map(item -> new GovernanceActivityItemResponse( + item.id(), + item.action(), + item.userId(), + item.username(), + item.resourceType(), + item.resourceId(), + item.details(), + item.timestamp() != null ? item.timestamp().toString() : null + )) + .toList(); + return new PageResponse<>(items, items.size(), page, size); + } + + private Page visiblePendingReviews(Map namespaceRoles, + Set platformRoles, + int size) { + if (hasPlatformGovernanceRole(platformRoles)) { + return reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, size)); + } + List tasks = namespaceRoles.entrySet().stream() + .filter(entry -> entry.getValue() == NamespaceRole.OWNER || entry.getValue() == NamespaceRole.ADMIN) + .map(entry -> reviewTaskRepository.findByNamespaceIdAndStatus(entry.getKey(), ReviewTaskStatus.PENDING, PageRequest.of(0, size))) + .flatMap(pageResult -> pageResult.getContent().stream()) + .toList(); + return new org.springframework.data.domain.PageImpl<>(tasks, PageRequest.of(0, size), tasks.size()); + } + + private GovernanceInboxItemResponse toReviewInboxItem(ReviewTask task) { + SkillVersion version = skillVersionRepository.findById(task.getSkillVersionId()).orElse(null); + Skill skill = version != null ? skillRepository.findById(version.getSkillId()).orElse(null) : null; + Namespace namespace = skill != null ? namespaceRepository.findById(skill.getNamespaceId()).orElse(null) : null; + String namespaceSlug = namespace != null ? namespace.getSlug() : null; + String skillSlug = skill != null ? skill.getSlug() : null; + String versionName = version != null ? version.getVersion() : null; + return new GovernanceInboxItemResponse( + "REVIEW", + task.getId(), + join(namespaceSlug, skillSlug, versionName), + "Pending review", + task.getSubmittedAt() != null ? task.getSubmittedAt().toString() : null, + namespaceSlug, + skillSlug + ); + } + + private GovernanceInboxItemResponse toPromotionInboxItem(PromotionRequest request) { + Skill skill = skillRepository.findById(request.getSourceSkillId()).orElse(null); + SkillVersion version = skillVersionRepository.findById(request.getSourceVersionId()).orElse(null); + Namespace sourceNamespace = skill != null ? namespaceRepository.findById(skill.getNamespaceId()).orElse(null) : null; + Namespace targetNamespace = namespaceRepository.findById(request.getTargetNamespaceId()).orElse(null); + String sourceSlug = sourceNamespace != null ? sourceNamespace.getSlug() : null; + String skillSlug = skill != null ? skill.getSlug() : null; + String targetSlug = targetNamespace != null ? targetNamespace.getSlug() : null; + String versionName = version != null ? version.getVersion() : null; + return new GovernanceInboxItemResponse( + "PROMOTION", + request.getId(), + join(sourceSlug, skillSlug, versionName), + targetSlug != null ? "Promote to @" + targetSlug : "Pending promotion", + request.getSubmittedAt() != null ? request.getSubmittedAt().toString() : null, + sourceSlug, + skillSlug + ); + } + + private GovernanceInboxItemResponse toReportInboxItem(SkillReport report) { + Skill skill = skillRepository.findById(report.getSkillId()).orElse(null); + Namespace namespace = namespaceRepository.findById(report.getNamespaceId()).orElse(null); + String namespaceSlug = namespace != null ? namespace.getSlug() : null; + String skillSlug = skill != null ? skill.getSlug() : null; + return new GovernanceInboxItemResponse( + "REPORT", + report.getId(), + join(namespaceSlug, skillSlug, null), + report.getReason(), + report.getCreatedAt() != null ? report.getCreatedAt().toString() : null, + namespaceSlug, + skillSlug + ); + } + + private String join(String namespaceSlug, String skillSlug, String version) { + String path = namespaceSlug != null && skillSlug != null ? namespaceSlug + "/" + skillSlug : "Unknown target"; + return version != null ? path + "@" + version : path; + } + + private boolean hasPlatformGovernanceRole(Set platformRoles) { + return platformRoles.contains("SKILL_ADMIN") || platformRoles.contains("SUPER_ADMIN"); + } + + private boolean canReadActivity(Set platformRoles) { + return hasPlatformGovernanceRole(platformRoles) + || platformRoles.contains("AUDITOR"); + } +} diff --git a/server/skillhub-app/src/main/resources/db/migration/V12__governance_notifications.sql b/server/skillhub-app/src/main/resources/db/migration/V12__governance_notifications.sql new file mode 100644 index 00000000..9a9c7368 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V12__governance_notifications.sql @@ -0,0 +1,15 @@ +CREATE TABLE user_notification ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(128) NOT NULL, + category VARCHAR(64) NOT NULL, + entity_type VARCHAR(64) NOT NULL, + entity_id BIGINT NOT NULL, + title VARCHAR(200) NOT NULL, + body_json TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'UNREAD', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + read_at TIMESTAMPTZ +); + +CREATE INDEX idx_user_notification_user_created_at ON user_notification(user_id, created_at DESC); +CREATE INDEX idx_user_notification_user_status ON user_notification(user_id, status, created_at DESC); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java new file mode 100644 index 00000000..2206116b --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java @@ -0,0 +1,159 @@ +package com.iflytek.skillhub.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.iflytek.skillhub.auth.device.DeviceAuthService; +import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; +import com.iflytek.skillhub.auth.rbac.RbacService; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; +import com.iflytek.skillhub.domain.governance.UserNotification; +import com.iflytek.skillhub.dto.GovernanceActivityItemResponse; +import com.iflytek.skillhub.dto.GovernanceInboxItemResponse; +import com.iflytek.skillhub.dto.GovernanceNotificationResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import com.iflytek.skillhub.service.GovernanceWorkbenchAppService; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class GovernanceControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private GovernanceWorkbenchAppService governanceWorkbenchAppService; + + @MockBean + private NamespaceMemberRepository namespaceMemberRepository; + + @MockBean + private DeviceAuthService deviceAuthService; + + @MockBean + private RbacService rbacService; + + @MockBean + private GovernanceNotificationService governanceNotificationService; + + @Test + void summary_returnsGovernanceSummary() throws Exception { + when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN")); + when(governanceWorkbenchAppService.getSummary("admin", Map.of(), Set.of("SKILL_ADMIN"))) + .thenReturn(new GovernanceSummaryResponse(3, 2, 1)); + + mockMvc.perform(get("/api/v1/governance/summary").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.pendingReviews").value(3)) + .andExpect(jsonPath("$.data.pendingPromotions").value(2)) + .andExpect(jsonPath("$.data.pendingReports").value(1)); + } + + @Test + void inbox_returnsUnifiedItems() throws Exception { + when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN")); + when(governanceWorkbenchAppService.listInbox("admin", Map.of(), Set.of("SKILL_ADMIN"), null, 0, 20)) + .thenReturn(new PageResponse<>( + List.of(new GovernanceInboxItemResponse( + "REVIEW", + 1L, + "team-a/skill-a@1.0.0", + "Pending review", + "2026-03-16T02:00:00Z", + "team-a", + "skill-a" + )), + 1, + 0, + 20 + )); + + mockMvc.perform(get("/api/v1/governance/inbox").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].type").value("REVIEW")); + } + + @Test + void activity_returnsGovernanceActivity() throws Exception { + when(rbacService.getUserRoleCodes("admin")).thenReturn(Set.of("SKILL_ADMIN")); + when(governanceWorkbenchAppService.listActivity(Set.of("SKILL_ADMIN"), 0, 20)) + .thenReturn(new PageResponse<>( + List.of(new GovernanceActivityItemResponse( + 1L, + "REVIEW_APPROVE", + "admin", + "Admin", + "REVIEW_TASK", + "99", + "{\"comment\":\"LGTM\"}", + Instant.parse("2026-03-16T02:00:00Z").toString() + )), + 1, + 0, + 20 + )); + + mockMvc.perform(get("/api/v1/governance/activity").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].action").value("REVIEW_APPROVE")); + } + + @Test + void notifications_returnsCurrentUserNotifications() throws Exception { + UserNotification notification = new UserNotification("admin", "REVIEW", "REVIEW_TASK", 99L, "Review approved", "{}"); + when(governanceNotificationService.listNotifications("admin")).thenReturn(List.of(notification)); + + mockMvc.perform(get("/api/v1/governance/notifications").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].category").value("REVIEW")); + } + + @Test + void markRead_returnsUpdatedNotification() throws Exception { + UserNotification notification = new UserNotification("admin", "REVIEW", "REVIEW_TASK", 99L, "Review approved", "{}"); + when(governanceNotificationService.markRead(10L, "admin")).thenReturn(notification); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/governance/notifications/10/read") + .with(org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf()) + .with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.category").value("REVIEW")); + } + + private RequestPostProcessor auth(String userId, Set roles) { + PlatformPrincipal principal = new PlatformPrincipal( + userId, + userId, + userId + "@example.com", + "", + "session", + roles + ); + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + principal, + null, + roles.stream().map(role -> new SimpleGrantedAuthority("ROLE_" + role)).toList() + ); + return authentication(authenticationToken); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java index 481d8ef0..193c3126 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java @@ -13,6 +13,7 @@ import com.iflytek.skillhub.TestRedisConfig; import com.iflytek.skillhub.auth.device.DeviceAuthService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.report.SkillReportDisposition; import com.iflytek.skillhub.domain.report.SkillReport; import com.iflytek.skillhub.domain.report.SkillReportService; import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; @@ -92,14 +93,20 @@ class AdminSkillReportControllerTest { SkillReport report = new SkillReport(10L, 1L, "user-1", "Spam", "details"); ReflectionTestUtils.setField(report, "id", 99L); report.setStatus(com.iflytek.skillhub.domain.report.SkillReportStatus.RESOLVED); - when(skillReportService.resolveReport(org.mockito.ArgumentMatchers.eq(99L), org.mockito.ArgumentMatchers.eq("admin"), org.mockito.ArgumentMatchers.eq("handled"), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any())) + when(skillReportService.resolveReport( + org.mockito.ArgumentMatchers.eq(99L), + org.mockito.ArgumentMatchers.eq("admin"), + org.mockito.ArgumentMatchers.eq(SkillReportDisposition.RESOLVE_AND_HIDE), + org.mockito.ArgumentMatchers.eq("handled"), + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any())) .thenReturn(report); mockMvc.perform(post("/api/v1/admin/skill-reports/99/resolve") .with(authentication(adminAuth())) .with(csrf()) .contentType(APPLICATION_JSON) - .content("{\"comment\":\"handled\"}")) + .content("{\"comment\":\"handled\",\"disposition\":\"RESOLVE_AND_HIDE\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.reportId").value(99)) .andExpect(jsonPath("$.data.status").value("RESOLVED")); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppServiceTest.java new file mode 100644 index 00000000..6e20f7e4 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/GovernanceWorkbenchAppServiceTest.java @@ -0,0 +1,253 @@ +package com.iflytek.skillhub.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.report.SkillReport; +import com.iflytek.skillhub.domain.report.SkillReportRepository; +import com.iflytek.skillhub.domain.report.SkillReportStatus; +import com.iflytek.skillhub.domain.review.PromotionRequest; +import com.iflytek.skillhub.domain.review.PromotionRequestRepository; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.dto.AuditLogItemResponse; +import com.iflytek.skillhub.dto.GovernanceSummaryResponse; +import com.iflytek.skillhub.dto.PageResponse; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@ExtendWith(MockitoExtension.class) +class GovernanceWorkbenchAppServiceTest { + + @Mock + private ReviewTaskRepository reviewTaskRepository; + + @Mock + private PromotionRequestRepository promotionRequestRepository; + + @Mock + private SkillReportRepository skillReportRepository; + + @Mock + private SkillRepository skillRepository; + + @Mock + private SkillVersionRepository skillVersionRepository; + + @Mock + private NamespaceRepository namespaceRepository; + + @Mock + private AdminAuditLogAppService adminAuditLogAppService; + + private GovernanceWorkbenchAppService service; + + @BeforeEach + void setUp() { + service = new GovernanceWorkbenchAppService( + reviewTaskRepository, + promotionRequestRepository, + skillReportRepository, + skillRepository, + skillVersionRepository, + namespaceRepository, + adminAuditLogAppService + ); + } + + @Test + void summary_returnsAllPendingCountsForPlatformGovernor() { + when(reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createReviewTask(1L, 11L, 101L, "owner")))); + when(promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createPromotionRequest(2L, 101L, 12L, "owner")))); + when(skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createReport(3L, 101L, 11L, "reporter")))); + + GovernanceSummaryResponse response = service.getSummary("admin", Map.of(), Set.of("SKILL_ADMIN")); + + assertThat(response.pendingReviews()).isEqualTo(1); + assertThat(response.pendingPromotions()).isEqualTo(1); + assertThat(response.pendingReports()).isEqualTo(1); + } + + @Test + void summary_limitsReviewsToManagedNamespacesForNamespaceAdmin() { + when(reviewTaskRepository.findByNamespaceIdAndStatus(11L, ReviewTaskStatus.PENDING, PageRequest.of(0, 100))) + .thenReturn(new PageImpl<>(List.of(createReviewTask(1L, 11L, 101L, "owner")))); + + GovernanceSummaryResponse response = service.getSummary( + "ns-admin", + Map.of(11L, NamespaceRole.ADMIN, 12L, NamespaceRole.MEMBER), + Set.of() + ); + + assertThat(response.pendingReviews()).isEqualTo(1); + assertThat(response.pendingPromotions()).isZero(); + assertThat(response.pendingReports()).isZero(); + } + + @Test + void listInbox_combinesReviewPromotionAndReportItems() { + ReviewTask reviewTask = createReviewTask(1L, 11L, 101L, "owner"); + PromotionRequest promotionRequest = createPromotionRequest(2L, 101L, 12L, "owner"); + SkillReport report = createReport(3L, 101L, 11L, "reporter"); + stubReviewContext(reviewTask, "team-a", "skill-a"); + stubPromotionContext(promotionRequest, "team-a", "skill-a", "global"); + stubReportContext(report, "team-a", "skill-a"); + + when(reviewTaskRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 20))) + .thenReturn(new PageImpl<>(List.of(reviewTask))); + when(promotionRequestRepository.findByStatus(ReviewTaskStatus.PENDING, PageRequest.of(0, 20))) + .thenReturn(new PageImpl<>(List.of(promotionRequest))); + when(skillReportRepository.findByStatus(SkillReportStatus.PENDING, PageRequest.of(0, 20))) + .thenReturn(new PageImpl<>(List.of(report))); + + PageResponse response = service.listInbox("admin", Map.of(), Set.of("SKILL_ADMIN"), null, 0, 20); + + assertThat(response.total()).isEqualTo(3); + assertThat(response.items()).hasSize(3); + } + + @Test + void listActivity_projectsGovernanceAuditEntries() { + when(adminAuditLogAppService.listAuditLogsByActions( + eq(0), + eq(20), + isNull(), + eq(Set.of( + "REVIEW_SUBMIT", + "REVIEW_APPROVE", + "REVIEW_REJECT", + "REVIEW_WITHDRAW", + "PROMOTION_SUBMIT", + "PROMOTION_APPROVE", + "PROMOTION_REJECT", + "REPORT_SKILL", + "RESOLVE_SKILL_REPORT", + "DISMISS_SKILL_REPORT", + "HIDE_SKILL", + "ARCHIVE_SKILL", + "UNHIDE_SKILL", + "UNARCHIVE_SKILL" + )), + isNull(), + isNull(), + isNull(), + isNull(), + isNull(), + isNull())) + .thenReturn(new PageResponse<>( + List.of( + new AuditLogItemResponse( + 1L, + "REVIEW_APPROVE", + "admin", + "Admin", + "{\"comment\":\"LGTM\"}", + "127.0.0.1", + "req-1", + "REVIEW_TASK", + "99", + Instant.parse("2026-03-16T02:00:00Z") + ) + ), + 1, + 0, + 20 + )); + + PageResponse response = service.listActivity(Set.of("SKILL_ADMIN"), 0, 20); + + assertThat(response.total()).isEqualTo(1); + assertThat(response.items()).hasSize(1); + } + + private void stubReviewContext(ReviewTask task, String namespaceSlug, String skillSlug) { + SkillVersion version = new SkillVersion(task.getSkillVersionId(), "1.0.0", task.getSubmittedBy()); + setField(version, "id", task.getSkillVersionId()); + setField(version, "skillId", task.getSkillVersionId()); + Skill skill = new Skill(task.getNamespaceId(), skillSlug, task.getSubmittedBy(), SkillVisibility.PUBLIC); + setField(skill, "id", task.getSkillVersionId()); + Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, task.getSubmittedBy()); + setField(namespace, "id", task.getNamespaceId()); + when(skillVersionRepository.findById(task.getSkillVersionId())).thenReturn(Optional.of(version)); + when(skillRepository.findById(task.getSkillVersionId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(task.getNamespaceId())).thenReturn(Optional.of(namespace)); + } + + private void stubPromotionContext(PromotionRequest request, String sourceNamespaceSlug, String skillSlug, String targetNamespaceSlug) { + Skill skill = new Skill(11L, skillSlug, request.getSubmittedBy(), SkillVisibility.PUBLIC); + setField(skill, "id", request.getSourceSkillId()); + SkillVersion version = new SkillVersion(request.getSourceSkillId(), "1.0.0", request.getSubmittedBy()); + setField(version, "id", request.getSourceVersionId()); + Namespace sourceNamespace = new Namespace(sourceNamespaceSlug, sourceNamespaceSlug, request.getSubmittedBy()); + setField(sourceNamespace, "id", 11L); + Namespace targetNamespace = new Namespace(targetNamespaceSlug, targetNamespaceSlug, request.getSubmittedBy()); + setField(targetNamespace, "id", request.getTargetNamespaceId()); + when(skillRepository.findById(request.getSourceSkillId())).thenReturn(Optional.of(skill)); + when(skillVersionRepository.findById(request.getSourceVersionId())).thenReturn(Optional.of(version)); + when(namespaceRepository.findById(11L)).thenReturn(Optional.of(sourceNamespace)); + when(namespaceRepository.findById(request.getTargetNamespaceId())).thenReturn(Optional.of(targetNamespace)); + } + + private void stubReportContext(SkillReport report, String namespaceSlug, String skillSlug) { + Skill skill = new Skill(report.getNamespaceId(), skillSlug, report.getReporterId(), SkillVisibility.PUBLIC); + setField(skill, "id", report.getSkillId()); + Namespace namespace = new Namespace(namespaceSlug, namespaceSlug, report.getReporterId()); + setField(namespace, "id", report.getNamespaceId()); + when(skillRepository.findById(report.getSkillId())).thenReturn(Optional.of(skill)); + when(namespaceRepository.findById(report.getNamespaceId())).thenReturn(Optional.of(namespace)); + } + + private ReviewTask createReviewTask(Long id, Long namespaceId, Long skillVersionId, String submittedBy) { + ReviewTask task = new ReviewTask(skillVersionId, namespaceId, submittedBy); + setField(task, "id", id); + return task; + } + + private PromotionRequest createPromotionRequest(Long id, Long sourceVersionId, Long targetNamespaceId, String submittedBy) { + PromotionRequest request = new PromotionRequest(sourceVersionId, sourceVersionId, targetNamespaceId, submittedBy); + setField(request, "id", id); + setField(request, "sourceSkillId", sourceVersionId); + return request; + } + + private SkillReport createReport(Long id, Long skillId, Long namespaceId, String reporterId) { + SkillReport report = new SkillReport(skillId, namespaceId, reporterId, "Spam", "details"); + setField(report, "id", id); + return report; + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java new file mode 100644 index 00000000..0dfd2046 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java @@ -0,0 +1,50 @@ +package com.iflytek.skillhub.domain.governance; + +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.util.List; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class GovernanceNotificationService { + + private final UserNotificationRepository userNotificationRepository; + + public GovernanceNotificationService(UserNotificationRepository userNotificationRepository) { + this.userNotificationRepository = userNotificationRepository; + } + + @Transactional + public UserNotification notifyUser(String userId, + String category, + String entityType, + Long entityId, + String title, + String bodyJson) { + return userNotificationRepository.save(new UserNotification( + userId, + category, + entityType, + entityId, + title, + bodyJson + )); + } + + @Transactional(readOnly = true) + public List listNotifications(String userId) { + return userNotificationRepository.findByUserIdOrderByCreatedAtDesc(userId); + } + + @Transactional + public UserNotification markRead(Long notificationId, String userId) { + UserNotification notification = userNotificationRepository.findById(notificationId) + .orElseThrow(() -> new DomainNotFoundException("error.notification.notFound", notificationId)); + if (!notification.getUserId().equals(userId)) { + throw new DomainForbiddenException("error.notification.noPermission"); + } + notification.markRead(); + return userNotificationRepository.save(notification); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java new file mode 100644 index 00000000..19f713b4 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java @@ -0,0 +1,116 @@ +package com.iflytek.skillhub.domain.governance; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.Instant; + +@Entity +@Table(name = "user_notification") +public class UserNotification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false, length = 128) + private String userId; + + @Column(nullable = false, length = 64) + private String category; + + @Column(name = "entity_type", nullable = false, length = 64) + private String entityType; + + @Column(name = "entity_id", nullable = false) + private Long entityId; + + @Column(nullable = false, length = 200) + private String title; + + @Column(name = "body_json", columnDefinition = "TEXT") + private String bodyJson; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private UserNotificationStatus status = UserNotificationStatus.UNREAD; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "read_at") + private Instant readAt; + + protected UserNotification() { + } + + public UserNotification(String userId, + String category, + String entityType, + Long entityId, + String title, + String bodyJson) { + this.userId = userId; + this.category = category; + this.entityType = entityType; + this.entityId = entityId; + this.title = title; + this.bodyJson = bodyJson; + } + + @PrePersist + void onCreate() { + createdAt = Instant.now(); + } + + public void markRead() { + this.status = UserNotificationStatus.READ; + this.readAt = Instant.now(); + } + + public Long getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public String getCategory() { + return category; + } + + public String getEntityType() { + return entityType; + } + + public Long getEntityId() { + return entityId; + } + + public String getTitle() { + return title; + } + + public String getBodyJson() { + return bodyJson; + } + + public UserNotificationStatus getStatus() { + return status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getReadAt() { + return readAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java new file mode 100644 index 00000000..85563adf --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.governance; + +import java.util.List; +import java.util.Optional; + +public interface UserNotificationRepository { + UserNotification save(UserNotification notification); + Optional findById(Long id); + List findByUserIdOrderByCreatedAtDesc(String userId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationStatus.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationStatus.java new file mode 100644 index 00000000..526daa9f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotificationStatus.java @@ -0,0 +1,6 @@ +package com.iflytek.skillhub.domain.governance; + +public enum UserNotificationStatus { + UNREAD, + READ +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportDisposition.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportDisposition.java new file mode 100644 index 00000000..c3b376ea --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportDisposition.java @@ -0,0 +1,7 @@ +package com.iflytek.skillhub.domain.report; + +public enum SkillReportDisposition { + RESOLVE_ONLY, + RESOLVE_AND_HIDE, + RESOLVE_AND_ARCHIVE +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java index 17b2722a..23965d71 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java @@ -1,11 +1,13 @@ package com.iflytek.skillhub.domain.report; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillStatus; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import java.time.LocalDateTime; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,13 +18,19 @@ public class SkillReportService { private final SkillRepository skillRepository; private final SkillReportRepository skillReportRepository; private final AuditLogService auditLogService; + private final SkillGovernanceService skillGovernanceService; + private final GovernanceNotificationService governanceNotificationService; public SkillReportService(SkillRepository skillRepository, SkillReportRepository skillReportRepository, - AuditLogService auditLogService) { + AuditLogService auditLogService, + SkillGovernanceService skillGovernanceService, + GovernanceNotificationService governanceNotificationService) { this.skillRepository = skillRepository; this.skillReportRepository = skillReportRepository; this.auditLogService = auditLogService; + this.skillGovernanceService = skillGovernanceService; + this.governanceNotificationService = governanceNotificationService; } @Transactional @@ -66,13 +74,36 @@ public class SkillReportService { String comment, String clientIp, String userAgent) { + return resolveReport(reportId, actorUserId, SkillReportDisposition.RESOLVE_ONLY, comment, clientIp, userAgent); + } + + @Transactional + public SkillReport resolveReport(Long reportId, + String actorUserId, + SkillReportDisposition disposition, + String comment, + String clientIp, + String userAgent) { SkillReport report = requirePendingReport(reportId); + if (disposition == SkillReportDisposition.RESOLVE_AND_HIDE) { + skillGovernanceService.hideSkill(report.getSkillId(), actorUserId, clientIp, userAgent, comment); + } else if (disposition == SkillReportDisposition.RESOLVE_AND_ARCHIVE) { + skillGovernanceService.archiveSkillAsAdmin(report.getSkillId(), actorUserId, clientIp, userAgent, comment); + } report.setStatus(SkillReportStatus.RESOLVED); report.setHandledBy(actorUserId); report.setHandleComment(normalize(comment)); report.setHandledAt(LocalDateTime.now()); SkillReport saved = skillReportRepository.save(report); auditLogService.record(actorUserId, "RESOLVE_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); + governanceNotificationService.notifyUser( + report.getReporterId(), + "REPORT", + "SKILL_REPORT", + reportId, + "Report handled", + "{\"status\":\"RESOLVED\"}" + ); return saved; } @@ -89,6 +120,14 @@ public class SkillReportService { report.setHandledAt(LocalDateTime.now()); SkillReport saved = skillReportRepository.save(report); auditLogService.record(actorUserId, "DISMISS_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); + governanceNotificationService.notifyUser( + report.getReporterId(), + "REPORT", + "SKILL_REPORT", + reportId, + "Report dismissed", + "{\"status\":\"DISMISSED\"}" + ); return saved; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index e7165d31..1809aa9e 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -30,6 +31,7 @@ public class PromotionService { private final NamespaceRepository namespaceRepository; private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; + private final GovernanceNotificationService governanceNotificationService; public PromotionService(PromotionRequestRepository promotionRequestRepository, SkillRepository skillRepository, @@ -37,7 +39,8 @@ public class PromotionService { SkillFileRepository skillFileRepository, NamespaceRepository namespaceRepository, ReviewPermissionChecker permissionChecker, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + GovernanceNotificationService governanceNotificationService) { this.promotionRequestRepository = promotionRequestRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -45,6 +48,7 @@ public class PromotionService { this.namespaceRepository = namespaceRepository; this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; + this.governanceNotificationService = governanceNotificationService; } @Transactional @@ -199,6 +203,14 @@ public class PromotionService { eventPublisher.publishEvent(new SkillPublishedEvent( newSkill.getId(), newVersion.getId(), reviewerId)); + governanceNotificationService.notifyUser( + request.getSubmittedBy(), + "PROMOTION", + "PROMOTION_REQUEST", + promotionId, + "Promotion approved", + "{\"status\":\"APPROVED\"}" + ); return request; } @@ -222,6 +234,14 @@ public class PromotionService { if (updated == 0) { throw new ConcurrentModificationException("Promotion request was modified concurrently"); } + governanceNotificationService.notifyUser( + request.getSubmittedBy(), + "PROMOTION", + "PROMOTION_REQUEST", + promotionId, + "Promotion rejected", + "{\"status\":\"REJECTED\"}" + ); return promotionRequestRepository.findById(promotionId).orElse(request); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index ef52d665..ec99bbc6 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -6,6 +6,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; @@ -37,6 +38,7 @@ public class ReviewService { private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; private final SkillGovernanceService skillGovernanceService; + private final GovernanceNotificationService governanceNotificationService; public ReviewService(ReviewTaskRepository reviewTaskRepository, SkillVersionRepository skillVersionRepository, @@ -45,7 +47,8 @@ public class ReviewService { ReviewPermissionChecker permissionChecker, ApplicationEventPublisher eventPublisher, ObjectMapper objectMapper, - SkillGovernanceService skillGovernanceService) { + SkillGovernanceService skillGovernanceService, + GovernanceNotificationService governanceNotificationService) { this.reviewTaskRepository = reviewTaskRepository; this.skillVersionRepository = skillVersionRepository; this.skillRepository = skillRepository; @@ -54,6 +57,7 @@ public class ReviewService { this.eventPublisher = eventPublisher; this.objectMapper = objectMapper; this.skillGovernanceService = skillGovernanceService; + this.governanceNotificationService = governanceNotificationService; } @Transactional @@ -162,6 +166,14 @@ public class ReviewService { eventPublisher.publishEvent(new SkillPublishedEvent( skill.getId(), skillVersion.getId(), reviewerId)); + governanceNotificationService.notifyUser( + task.getSubmittedBy(), + "REVIEW", + "REVIEW_TASK", + reviewTaskId, + "Review approved", + "{\"status\":\"APPROVED\"}" + ); // Reload to return updated state return reviewTaskRepository.findById(reviewTaskId).orElse(task); @@ -197,6 +209,14 @@ public class ReviewService { .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", task.getSkillVersionId())); skillVersion.setStatus(SkillVersionStatus.REJECTED); skillVersionRepository.save(skillVersion); + governanceNotificationService.notifyUser( + task.getSubmittedBy(), + "REVIEW", + "REVIEW_TASK", + reviewTaskId, + "Review rejected", + "{\"status\":\"REJECTED\"}" + ); return reviewTaskRepository.findById(reviewTaskId).orElse(task); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index 2e486a11..8e7d8c9d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -69,13 +69,31 @@ public class SkillGovernanceService { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); assertCanManageLifecycle(skill, actorUserId, userNamespaceRoles); + return archiveSkillInternal(skill, actorUserId, clientIp, userAgent, reason); + } + @Transactional + public Skill archiveSkillAsAdmin(Long skillId, + String actorUserId, + String clientIp, + String userAgent, + String reason) { + Skill skill = skillRepository.findById(skillId) + .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); + return archiveSkillInternal(skill, actorUserId, clientIp, userAgent, reason); + } + + private Skill archiveSkillInternal(Skill skill, + String actorUserId, + String clientIp, + String userAgent, + String reason) { SkillStatus previousStatus = skill.getStatus(); skill.setStatus(SkillStatus.ARCHIVED); skill.setUpdatedBy(actorUserId); Skill saved = skillRepository.save(skill); - auditLogService.record(actorUserId, "ARCHIVE_SKILL", "SKILL", skillId, null, clientIp, userAgent, jsonReason(reason)); - eventPublisher.publishEvent(new SkillStatusChangedEvent(skillId, previousStatus, SkillStatus.ARCHIVED)); + auditLogService.record(actorUserId, "ARCHIVE_SKILL", "SKILL", skill.getId(), null, clientIp, userAgent, jsonReason(reason)); + eventPublisher.publishEvent(new SkillStatusChangedEvent(skill.getId(), previousStatus, SkillStatus.ARCHIVED)); return saved; } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java new file mode 100644 index 00000000..6ecfedbc --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java @@ -0,0 +1,85 @@ +package com.iflytek.skillhub.domain.governance; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GovernanceNotificationServiceTest { + + @Mock + private UserNotificationRepository userNotificationRepository; + + private GovernanceNotificationService service; + + @BeforeEach + void setUp() { + service = new GovernanceNotificationService(userNotificationRepository); + } + + @Test + void notifyUser_createsUnreadNotification() { + when(userNotificationRepository.save(any(UserNotification.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + UserNotification notification = service.notifyUser( + "user-1", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review completed", + "{\"status\":\"APPROVED\"}" + ); + + assertThat(notification.getUserId()).isEqualTo("user-1"); + assertThat(notification.getStatus()).isEqualTo(UserNotificationStatus.UNREAD); + assertThat(notification.getCategory()).isEqualTo("REVIEW"); + } + + @Test + void markRead_requiresOwner() { + UserNotification notification = new UserNotification( + "user-1", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review completed", + "{\"status\":\"APPROVED\"}" + ); + setField(notification, "id", 10L); + when(userNotificationRepository.findById(10L)).thenReturn(Optional.of(notification)); + + assertThrows(DomainForbiddenException.class, () -> service.markRead(10L, "user-2")); + } + + @Test + void listNotifications_returnsNewestFirst() { + UserNotification unread = new UserNotification("user-1", "REVIEW", "REVIEW_TASK", 99L, "A", "{}"); + UserNotification read = new UserNotification("user-1", "REPORT", "SKILL_REPORT", 88L, "B", "{}"); + when(userNotificationRepository.findByUserIdOrderByCreatedAtDesc("user-1")).thenReturn(List.of(unread, read)); + + List result = service.listNotifications("user-1"); + + assertThat(result).hasSize(2); + } + + private void setField(Object target, String fieldName, Object value) { + try { + java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java index 5495f397..9fe73265 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java @@ -3,14 +3,17 @@ package com.iflytek.skillhub.domain.report; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -30,11 +33,23 @@ class SkillReportServiceTest { @Mock private AuditLogService auditLogService; + @Mock + private SkillGovernanceService skillGovernanceService; + + @Mock + private GovernanceNotificationService governanceNotificationService; + private SkillReportService service; @BeforeEach void setUp() { - service = new SkillReportService(skillRepository, skillReportRepository, auditLogService); + service = new SkillReportService( + skillRepository, + skillReportRepository, + auditLogService, + skillGovernanceService, + governanceNotificationService + ); } @Test @@ -90,6 +105,53 @@ class SkillReportServiceTest { assertThat(saved.getHandledBy()).isEqualTo("admin"); } + @Test + void resolveReport_withHideDisposition_hidesSkillAndNotifiesReporter() { + SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null); + setField(report, "id", 99L); + when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report)); + when(skillReportRepository.save(report)).thenReturn(report); + + SkillReport saved = service.resolveReport( + 99L, + "admin", + SkillReportDisposition.RESOLVE_AND_HIDE, + "handled", + "127.0.0.1", + "JUnit" + ); + + assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED); + verify(skillGovernanceService).hideSkill(10L, "admin", "127.0.0.1", "JUnit", "handled"); + verify(governanceNotificationService).notifyUser( + eq("user-1"), + eq("REPORT"), + eq("SKILL_REPORT"), + eq(99L), + eq("Report handled"), + any() + ); + } + + @Test + void resolveReport_withArchiveDisposition_archivesSkill() { + SkillReport report = new SkillReport(10L, 1L, "user-1", "spam", null); + setField(report, "id", 99L); + when(skillReportRepository.findById(99L)).thenReturn(Optional.of(report)); + when(skillReportRepository.save(report)).thenReturn(report); + + service.resolveReport( + 99L, + "admin", + SkillReportDisposition.RESOLVE_AND_ARCHIVE, + "handled", + "127.0.0.1", + "JUnit" + ); + + verify(skillGovernanceService).archiveSkillAsAdmin(10L, "admin", "127.0.0.1", "JUnit", "handled"); + } + private void setField(Object target, String fieldName, Object value) { try { java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index 188475be..863b47fb 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -1,6 +1,7 @@ package com.iflytek.skillhub.domain.review; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; @@ -35,6 +36,7 @@ class PromotionServiceTest { @Mock private NamespaceRepository namespaceRepository; @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private GovernanceNotificationService governanceNotificationService; private PromotionService promotionService; @@ -51,7 +53,7 @@ class PromotionServiceTest { void setUp() { promotionService = new PromotionService( promotionRequestRepository, skillRepository, skillVersionRepository, - skillFileRepository, namespaceRepository, permissionChecker, eventPublisher); + skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService); } private static void setField(Object target, String fieldName, Object value) { @@ -308,6 +310,52 @@ class PromotionServiceTest { } } + @Nested + class ReviewPromotion { + + @Test + void shouldNotifySubmitterWhenPromotionApproved() { + PromotionRequest request = createPendingPromotion(); + Skill sourceSkill = createSourceSkill(); + SkillVersion sourceVersion = createPublishedVersion(); + Skill newSkill = new Skill(TARGET_NAMESPACE_ID, "my-skill", REVIEWER_ID, SkillVisibility.PUBLIC); + setField(newSkill, "id", NEW_SKILL_ID); + SkillVersion newVersion = new SkillVersion(NEW_SKILL_ID, sourceVersion.getVersion(), REVIEWER_ID); + setField(newVersion, "id", NEW_VERSION_ID); + + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + when(permissionChecker.canReviewPromotion(request, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "ok", null, request.getVersion())) + .thenReturn(1); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(skillRepository.save(any(Skill.class))).thenReturn(newSkill); + when(skillVersionRepository.save(any(SkillVersion.class))).thenReturn(newVersion); + when(skillFileRepository.findByVersionId(SOURCE_VERSION_ID)).thenReturn(List.of()); + + promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion approved"), any()); + } + + @Test + void shouldNotifySubmitterWhenPromotionRejected() { + PromotionRequest request = createPendingPromotion(); + + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + when(permissionChecker.canReviewPromotion(request, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "no", null, request.getVersion())) + .thenReturn(1); + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(request)); + + promotionService.rejectPromotion(PROMOTION_ID, REVIEWER_ID, "no", Set.of("SKILL_ADMIN")); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("PROMOTION"), eq("PROMOTION_REQUEST"), eq(PROMOTION_ID), eq("Promotion rejected"), any()); + } + } + @Nested class ApprovePromotion { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index ee5072df..2ceb4562 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -2,6 +2,7 @@ package com.iflytek.skillhub.domain.review; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.event.SkillPublishedEvent; +import com.iflytek.skillhub.domain.governance.GovernanceNotificationService; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -46,6 +47,7 @@ class ReviewServiceTest { @Mock private ReviewPermissionChecker permissionChecker; @Mock private ApplicationEventPublisher eventPublisher; @Mock private SkillGovernanceService skillGovernanceService; + @Mock private GovernanceNotificationService governanceNotificationService; private ReviewService reviewService; @@ -62,7 +64,7 @@ class ReviewServiceTest { objectMapper = new ObjectMapper(); reviewService = new ReviewService( reviewTaskRepository, skillVersionRepository, skillRepository, - namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService); + namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService); } private SkillVersion createDraftSkillVersion() { @@ -245,6 +247,7 @@ class ReviewServiceTest { assertEquals("Approved Summary", skill.getSummary()); assertEquals(REVIEWER_ID, skill.getUpdatedBy()); verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review approved"), any()); } @Test @@ -273,6 +276,28 @@ class ReviewServiceTest { assertEquals(REVIEWER_ID, event.publisherId()); } + @Test + void shouldNotifySubmitterWhenRejected() { + ReviewTask task = createPendingReviewTask(); + Namespace ns = createTeamNamespace(); + SkillVersion sv = createPendingReviewSkillVersion(); + + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + when(namespaceRepository.findById(NAMESPACE_ID)).thenReturn(Optional.of(ns)); + when(permissionChecker.canReview(eq(task), eq(REVIEWER_ID), eq(ns.getType()), anyMap(), anySet())) + .thenReturn(true); + when(reviewTaskRepository.updateStatusWithVersion( + REVIEW_TASK_ID, ReviewTaskStatus.REJECTED, REVIEWER_ID, "Needs work", task.getVersion())) + .thenReturn(1); + when(skillVersionRepository.findById(SKILL_VERSION_ID)).thenReturn(Optional.of(sv)); + when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.of(task)); + + reviewService.rejectReview(REVIEW_TASK_ID, REVIEWER_ID, "Needs work", + Map.of(NAMESPACE_ID, NamespaceRole.ADMIN), Set.of()); + + verify(governanceNotificationService).notifyUser(eq(USER_ID), eq("REVIEW"), eq("REVIEW_TASK"), eq(REVIEW_TASK_ID), eq("Review rejected"), any()); + } + @Test void shouldThrowWhenReviewTaskNotFound() { when(reviewTaskRepository.findById(REVIEW_TASK_ID)).thenReturn(Optional.empty()); diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java new file mode 100644 index 00000000..a4e461f6 --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/UserNotificationJpaRepository.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.infra.jpa; + +import com.iflytek.skillhub.domain.governance.UserNotification; +import com.iflytek.skillhub.domain.governance.UserNotificationRepository; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserNotificationJpaRepository extends JpaRepository, UserNotificationRepository { + List findByUserIdOrderByCreatedAtDesc(String userId); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index fc3755c6..a46c7bd5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -16,6 +16,11 @@ import type { AuditLogItem, SkillSummary, SkillReport, + GovernanceSummary, + GovernanceInboxItem, + GovernanceActivityItem, + GovernanceNotification, + ReportDisposition, AuthMethod, OAuthProvider, User, @@ -745,13 +750,13 @@ export const reportApi = { ) }, - async resolveSkillReport(id: number, comment?: string): Promise { + async resolveSkillReport(id: number, comment?: string, disposition: ReportDisposition = 'RESOLVE_ONLY'): Promise { await fetchJson(`/api/v1/admin/skill-reports/${id}/resolve`, { method: 'POST', headers: getCsrfHeaders({ 'Content-Type': 'application/json', }), - body: JSON.stringify({ comment }), + body: JSON.stringify({ comment, disposition }), }) }, @@ -766,6 +771,42 @@ export const reportApi = { }, } +export const governanceApi = { + async getSummary(): Promise { + return fetchJson(`${WEB_API_PREFIX}/governance/summary`) + }, + + async getInbox(params: { type?: string; page?: number; size?: number }) { + const searchParams = new URLSearchParams() + if (params.type) searchParams.set('type', params.type) + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: GovernanceInboxItem[]; total: number; page: number; size: number }>( + `${WEB_API_PREFIX}/governance/inbox?${searchParams.toString()}`, + ) + }, + + async getActivity(params: { page?: number; size?: number }) { + const searchParams = new URLSearchParams() + searchParams.set('page', String(params.page ?? 0)) + searchParams.set('size', String(params.size ?? 20)) + return fetchJson<{ items: GovernanceActivityItem[]; total: number; page: number; size: number }>( + `${WEB_API_PREFIX}/governance/activity?${searchParams.toString()}`, + ) + }, + + async getNotifications(): Promise { + return fetchJson(`${WEB_API_PREFIX}/governance/notifications`) + }, + + async markNotificationRead(id: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/governance/notifications/${id}/read`, { + method: 'POST', + headers: getCsrfHeaders(), + }) + }, +} + export const meApi = { async getStars(): Promise { return fetchJson(`${WEB_API_PREFIX}/me/stars`) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index aff9eb68..b9fe330a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -278,6 +278,47 @@ export interface SkillReport { handledAt?: string } +export type ReportDisposition = 'RESOLVE_ONLY' | 'RESOLVE_AND_HIDE' | 'RESOLVE_AND_ARCHIVE' + +export interface GovernanceSummary { + pendingReviews: number + pendingPromotions: number + pendingReports: number +} + +export interface GovernanceInboxItem { + type: 'REVIEW' | 'PROMOTION' | 'REPORT' | string + id: number + title: string + subtitle?: string + timestamp?: string + namespace?: string + skillSlug?: string +} + +export interface GovernanceActivityItem { + id: number + action: string + actorUserId?: string + actorDisplayName?: string + targetType?: string + targetId?: string + details?: string + timestamp?: string +} + +export interface GovernanceNotification { + id?: number + category: string + entityType: string + entityId: number + title: string + bodyJson?: string + status: 'UNREAD' | 'READ' | string + createdAt?: string + readAt?: string +} + export interface AdminUser { userId: string username: string diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 0d9af6f3..63783b87 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -50,6 +50,7 @@ const NamespaceReviewsPage = createLazyRouteComponent( () => import('@/pages/dashboard/namespace-reviews'), 'NamespaceReviewsPage', ) +const GovernancePage = createLazyRouteComponent(() => import('@/pages/dashboard/governance'), 'GovernancePage') const ReviewsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reviews'), 'ReviewsPage') const ReportsPage = createLazyRouteComponent(() => import('@/pages/dashboard/reports'), 'ReportsPage') const ReviewDetailPage = createLazyRouteComponent( @@ -206,6 +207,13 @@ const dashboardNamespaceReviewsRoute = createRoute({ component: NamespaceReviewsPage, }) +const dashboardGovernanceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/governance', + beforeLoad: requireAuth, + component: GovernancePage, +}) + const dashboardReviewsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/reviews', @@ -321,6 +329,7 @@ const routeTree = rootRoute.addChildren([ dashboardNamespacesRoute, dashboardNamespaceMembersRoute, dashboardNamespaceReviewsRoute, + dashboardGovernanceRoute, dashboardReviewsRoute, dashboardReportsRoute, dashboardReviewDetailRoute, diff --git a/web/src/features/governance/governance-activity.tsx b/web/src/features/governance/governance-activity.tsx new file mode 100644 index 00000000..271773eb --- /dev/null +++ b/web/src/features/governance/governance-activity.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next' +import type { GovernanceActivityItem } from '@/api/types' +import { Card } from '@/shared/ui/card' + +interface GovernanceActivityProps { + items?: GovernanceActivityItem[] + isLoading: boolean +} + +export function GovernanceActivity({ items, isLoading }: GovernanceActivityProps) { + const { t, i18n } = useTranslation() + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return {t('governance.emptyActivity')} + } + + return ( +
+ {items.map((item) => ( + +
+
{item.action}
+
+ {item.timestamp ? new Date(item.timestamp).toLocaleString(i18n.language) : '-'} +
+
+
+ {item.actorDisplayName || item.actorUserId || t('governance.unknownActor')} +
+ {item.details ?
{item.details}
: null} +
+ ))} +
+ ) +} diff --git a/web/src/features/governance/governance-inbox.tsx b/web/src/features/governance/governance-inbox.tsx new file mode 100644 index 00000000..97e26d0b --- /dev/null +++ b/web/src/features/governance/governance-inbox.tsx @@ -0,0 +1,69 @@ +import { useNavigate } from '@tanstack/react-router' +import { useTranslation } from 'react-i18next' +import type { GovernanceInboxItem } from '@/api/types' +import { Card } from '@/shared/ui/card' +import { Button } from '@/shared/ui/button' + +interface GovernanceInboxProps { + items?: GovernanceInboxItem[] + isLoading: boolean +} + +export function GovernanceInbox({ items, isLoading }: GovernanceInboxProps) { + const { t, i18n } = useTranslation() + const navigate = useNavigate() + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return {t('governance.emptyInbox')} + } + + const openItem = (item: GovernanceInboxItem) => { + if (item.type === 'REVIEW') { + navigate({ to: `/dashboard/reviews/${item.id}` }) + return + } + if (item.type === 'PROMOTION') { + navigate({ to: '/dashboard/promotions' }) + return + } + if (item.type === 'REPORT') { + navigate({ to: '/dashboard/reports' }) + return + } + if (item.namespace && item.skillSlug) { + navigate({ to: `/space/${item.namespace}/${item.skillSlug}` }) + } + } + + return ( +
+ {items.map((item) => ( + +
+
+
+ + {item.type} + +
{item.title}
+
+ {item.subtitle ?
{item.subtitle}
: null} +
+
+ {item.timestamp ? new Date(item.timestamp).toLocaleString(i18n.language) : '-'} +
+
+
+ +
+
+ ))} +
+ ) +} diff --git a/web/src/features/governance/governance-notifications.tsx b/web/src/features/governance/governance-notifications.tsx new file mode 100644 index 00000000..d4db6af6 --- /dev/null +++ b/web/src/features/governance/governance-notifications.tsx @@ -0,0 +1,51 @@ +import { useTranslation } from 'react-i18next' +import type { GovernanceNotification } from '@/api/types' +import { Card } from '@/shared/ui/card' +import { Button } from '@/shared/ui/button' + +interface GovernanceNotificationsProps { + items?: GovernanceNotification[] + isLoading: boolean + onMarkRead: (id: number) => void + isMarkingRead: boolean +} + +export function GovernanceNotifications({ items, isLoading, onMarkRead, isMarkingRead }: GovernanceNotificationsProps) { + const { t, i18n } = useTranslation() + + if (isLoading) { + return
+ } + + if (!items || items.length === 0) { + return {t('governance.emptyNotifications')} + } + + return ( +
+ {items.map((item) => ( + +
+
{item.title}
+ + {item.status} + +
+ {item.createdAt ? ( +
+ {new Date(item.createdAt).toLocaleString(i18n.language)} +
+ ) : null} + {item.bodyJson ?
{item.bodyJson}
: null} + {item.status === 'UNREAD' && item.id ? ( +
+ +
+ ) : null} +
+ ))} +
+ ) +} diff --git a/web/src/features/governance/use-governance.ts b/web/src/features/governance/use-governance.ts new file mode 100644 index 00000000..f55a9e3c --- /dev/null +++ b/web/src/features/governance/use-governance.ts @@ -0,0 +1,46 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { governanceApi } from '@/api/client' + +export function useGovernanceSummary() { + return useQuery({ + queryKey: ['governance', 'summary'], + queryFn: () => governanceApi.getSummary(), + }) +} + +export function useGovernanceInbox(type?: string) { + return useQuery({ + queryKey: ['governance', 'inbox', type ?? 'ALL'], + queryFn: async () => { + const page = await governanceApi.getInbox({ type }) + return page.items + }, + }) +} + +export function useGovernanceActivity() { + return useQuery({ + queryKey: ['governance', 'activity'], + queryFn: async () => { + const page = await governanceApi.getActivity({}) + return page.items + }, + }) +} + +export function useGovernanceNotifications() { + return useQuery({ + queryKey: ['governance', 'notifications'], + queryFn: () => governanceApi.getNotifications(), + }) +} + +export function useMarkGovernanceNotificationRead() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: number) => governanceApi.markNotificationRead(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['governance', 'notifications'] }) + }, + }) +} diff --git a/web/src/features/promotion/use-promotion-list.ts b/web/src/features/promotion/use-promotion-list.ts index f7521a7d..7fa5fd9c 100644 --- a/web/src/features/promotion/use-promotion-list.ts +++ b/web/src/features/promotion/use-promotion-list.ts @@ -26,6 +26,7 @@ export function useApprovePromotion() { mutationFn: ({ id, comment }: { id: number; comment?: string }) => promotionApi.approve(id, comment), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['promotions'] }) + queryClient.invalidateQueries({ queryKey: ['governance'] }) }, }) } @@ -36,6 +37,7 @@ export function useRejectPromotion() { mutationFn: ({ id, comment }: { id: number; comment?: string }) => promotionApi.reject(id, comment), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['promotions'] }) + queryClient.invalidateQueries({ queryKey: ['governance'] }) }, }) } diff --git a/web/src/features/report/use-skill-reports.ts b/web/src/features/report/use-skill-reports.ts index 284cce4e..2a714ccf 100644 --- a/web/src/features/report/use-skill-reports.ts +++ b/web/src/features/report/use-skill-reports.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { reportApi } from '@/api/client' +import type { ReportDisposition } from '@/api/types' export function useSkillReports(status: string) { return useQuery({ @@ -20,9 +21,11 @@ export function useSubmitSkillReport(namespace: string, slug: string) { export function useResolveSkillReport() { const queryClient = useQueryClient() return useMutation({ - mutationFn: ({ id, comment }: { id: number; comment?: string }) => reportApi.resolveSkillReport(id, comment), + mutationFn: ({ id, comment, disposition }: { id: number; comment?: string; disposition?: ReportDisposition }) => + reportApi.resolveSkillReport(id, comment, disposition), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['skill-reports'] }) + queryClient.invalidateQueries({ queryKey: ['governance'] }) }, }) } @@ -33,6 +36,7 @@ export function useDismissSkillReport() { mutationFn: ({ id, comment }: { id: number; comment?: string }) => reportApi.dismissSkillReport(id, comment), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['skill-reports'] }) + queryClient.invalidateQueries({ queryKey: ['governance'] }) }, }) } diff --git a/web/src/features/review/use-review-detail.ts b/web/src/features/review/use-review-detail.ts index 5a70738b..7d4a7131 100644 --- a/web/src/features/review/use-review-detail.ts +++ b/web/src/features/review/use-review-detail.ts @@ -30,6 +30,7 @@ export function useApproveReview() { approveReview(taskId, comment), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reviews'] }) + queryClient.invalidateQueries({ queryKey: ['governance'] }) }, }) } @@ -42,6 +43,7 @@ export function useRejectReview() { rejectReview(taskId, comment), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['reviews'] }) + queryClient.invalidateQueries({ queryKey: ['governance'] }) }, }) } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 74a4fe6d..61749095 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -226,6 +226,7 @@ "credentials": "Credentials", "openTokens": "Open Token Page", "governanceTitle": "Review & Governance", + "viewGovernance": "Open governance center", "viewPromotions": "View Promotions", "reportsTitle": "Report Management", "viewReports": "View skill reports" @@ -406,9 +407,19 @@ "filterAll": "All", "filterCliPublish": "CLI Publish", "filterCompatPublish": "Compat Publish", + "filterReviewSubmit": "Review Submitted", "filterReviewApprove": "Review Approved", "filterReviewReject": "Review Rejected", + "filterPromotionSubmit": "Promotion Submitted", "filterPromotionApprove": "Promotion Approved", + "filterPromotionReject": "Promotion Rejected", + "filterReportSkill": "Skill Reported", + "filterResolveSkillReport": "Report Resolved", + "filterDismissSkillReport": "Report Dismissed", + "filterHideSkill": "Skill Hidden", + "filterArchiveSkill": "Skill Archived", + "filterUnhideSkill": "Skill Unhidden", + "filterUnarchiveSkill": "Skill Restored", "filterYankVersion": "Version Yanked", "userIdPlaceholder": "User ID...", "requestIdPlaceholder": "Request ID...", @@ -578,18 +589,52 @@ "reporter": "Reporter", "handledBy": "Handled by", "resolve": "Resolve", + "resolveAndHide": "Resolve & hide", + "resolveAndArchive": "Resolve & archive", "dismiss": "Dismiss", "resolveConfirmTitle": "Resolve report", "resolveConfirmDescription": "Mark the report for \"{{skill}}\" as handled?", + "resolveAndHideConfirmDescription": "Resolve the report for \"{{skill}}\" and hide the skill from public access?", + "resolveAndArchiveConfirmDescription": "Resolve the report for \"{{skill}}\" and archive the skill?", "dismissConfirmTitle": "Dismiss report", "dismissConfirmDescription": "Dismiss the report for \"{{skill}}\"?", "resolveSuccessTitle": "Report resolved", "resolveSuccessDescription": "The report for \"{{skill}}\" has been marked as handled.", + "resolveAndHideSuccessTitle": "Report resolved and skill hidden", + "resolveAndHideSuccessDescription": "\"{{skill}}\" has been hidden after the report was resolved.", + "resolveAndArchiveSuccessTitle": "Report resolved and skill archived", + "resolveAndArchiveSuccessDescription": "\"{{skill}}\" has been archived after the report was resolved.", "dismissSuccessTitle": "Report dismissed", "dismissSuccessDescription": "The report for \"{{skill}}\" has been dismissed.", "resolveErrorTitle": "Failed to resolve report", + "resolveAndHideErrorTitle": "Failed to resolve report and hide skill", + "resolveAndArchiveErrorTitle": "Failed to resolve report and archive skill", "dismissErrorTitle": "Failed to dismiss report" }, + "governance": { + "title": "Governance Center", + "subtitle": "Track reviews, promotions, reports, and audit activity in one place.", + "pendingReviews": "Pending reviews", + "pendingPromotions": "Pending promotions", + "pendingReports": "Pending reports", + "unreadNotifications": "Unread notifications", + "inboxTitle": "Governance inbox", + "inboxSubtitle": "Unified queue for operational work that still needs action.", + "tabAll": "All", + "tabReview": "Reviews", + "tabPromotion": "Promotions", + "tabReport": "Reports", + "emptyInbox": "No governance work items right now.", + "openItem": "Open", + "notificationsTitle": "Notifications", + "notificationsSubtitle": "Recent governance updates that need your attention.", + "emptyNotifications": "No notifications yet.", + "markRead": "Mark as read", + "activityTitle": "Governance activity", + "activitySubtitle": "Recent audit events for review, promotion, report, and lifecycle actions.", + "emptyActivity": "No recent governance activity.", + "unknownActor": "Unknown actor" + }, "members": { "title": "Member Management", "addMember": "Add Member", @@ -730,6 +775,7 @@ "dashboard": "Dashboard", "mySkills": "My Skills", "myNamespaces": "My Namespaces", + "governance": "Governance Center", "stars": "Starred", "reviews": "Review Management", "promotions": "Promotion Management", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 8078e767..12f6879b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -226,6 +226,7 @@ "credentials": "访问凭证", "openTokens": "打开 Token 页面", "governanceTitle": "审核与治理", + "viewGovernance": "打开治理中心", "viewPromotions": "查看提升审核", "reportsTitle": "举报管理", "viewReports": "查看技能举报" @@ -406,9 +407,19 @@ "filterAll": "全部", "filterCliPublish": "CLI 发布", "filterCompatPublish": "Compat 发布", + "filterReviewSubmit": "提交审核", "filterReviewApprove": "审核通过", "filterReviewReject": "审核拒绝", + "filterPromotionSubmit": "提交提升", "filterPromotionApprove": "提升通过", + "filterPromotionReject": "提升拒绝", + "filterReportSkill": "提交举报", + "filterResolveSkillReport": "处理举报", + "filterDismissSkillReport": "驳回举报", + "filterHideSkill": "隐藏技能", + "filterArchiveSkill": "归档技能", + "filterUnhideSkill": "恢复隐藏", + "filterUnarchiveSkill": "恢复归档", "filterYankVersion": "版本撤回", "userIdPlaceholder": "用户 ID...", "requestIdPlaceholder": "请求 ID...", @@ -578,18 +589,52 @@ "reporter": "举报人", "handledBy": "处理人", "resolve": "标记处理", + "resolveAndHide": "处理并隐藏", + "resolveAndArchive": "处理并归档", "dismiss": "驳回举报", "resolveConfirmTitle": "确认处理举报", "resolveConfirmDescription": "确认将“{{skill}}”的举报标记为已处理吗?", + "resolveAndHideConfirmDescription": "确认处理“{{skill}}”的举报,并立即隐藏该技能吗?", + "resolveAndArchiveConfirmDescription": "确认处理“{{skill}}”的举报,并立即归档该技能吗?", "dismissConfirmTitle": "确认驳回举报", "dismissConfirmDescription": "确认驳回“{{skill}}”的这条举报吗?", "resolveSuccessTitle": "举报已处理", "resolveSuccessDescription": "“{{skill}}”的举报已标记为已处理。", + "resolveAndHideSuccessTitle": "举报已处理并隐藏技能", + "resolveAndHideSuccessDescription": "“{{skill}}”已根据举报结果被隐藏。", + "resolveAndArchiveSuccessTitle": "举报已处理并归档技能", + "resolveAndArchiveSuccessDescription": "“{{skill}}”已根据举报结果被归档。", "dismissSuccessTitle": "举报已驳回", "dismissSuccessDescription": "“{{skill}}”的举报已驳回。", "resolveErrorTitle": "处理举报失败", + "resolveAndHideErrorTitle": "处理举报并隐藏技能失败", + "resolveAndArchiveErrorTitle": "处理举报并归档技能失败", "dismissErrorTitle": "驳回举报失败" }, + "governance": { + "title": "治理中心", + "subtitle": "在一个视图里跟踪审核、提升、举报和审计活动。", + "pendingReviews": "待审核", + "pendingPromotions": "待提升审核", + "pendingReports": "待处理举报", + "unreadNotifications": "未读通知", + "inboxTitle": "治理待办", + "inboxSubtitle": "集中查看仍需处理的审核、提升和举报任务。", + "tabAll": "全部", + "tabReview": "审核", + "tabPromotion": "提升", + "tabReport": "举报", + "emptyInbox": "当前没有待处理的治理任务。", + "openItem": "打开", + "notificationsTitle": "治理通知", + "notificationsSubtitle": "查看最近需要你关注的治理更新。", + "emptyNotifications": "暂无治理通知。", + "markRead": "标记已读", + "activityTitle": "治理活动", + "activitySubtitle": "最近的审核、提升、举报和生命周期治理审计记录。", + "emptyActivity": "暂无治理活动。", + "unknownActor": "未知操作者" + }, "members": { "title": "成员管理", "addMember": "添加成员", @@ -730,6 +775,7 @@ "dashboard": "控制台", "mySkills": "我的技能", "myNamespaces": "我的命名空间", + "governance": "治理中心", "stars": "我的收藏", "reviews": "审核管理", "promotions": "推广管理", diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx index 713b8327..d1124a89 100644 --- a/web/src/pages/admin/audit-log.tsx +++ b/web/src/pages/admin/audit-log.tsx @@ -14,6 +14,26 @@ import { } from '@/shared/ui/table' import { useAuditLog } from '@/features/admin/use-audit-log' +const ACTION_OPTIONS = [ + { value: '', labelKey: 'auditLog.filterAll' }, + { value: 'CLI_PUBLISH', labelKey: 'auditLog.filterCliPublish' }, + { value: 'COMPAT_PUBLISH', labelKey: 'auditLog.filterCompatPublish' }, + { value: 'REVIEW_SUBMIT', labelKey: 'auditLog.filterReviewSubmit' }, + { value: 'REVIEW_APPROVE', labelKey: 'auditLog.filterReviewApprove' }, + { value: 'REVIEW_REJECT', labelKey: 'auditLog.filterReviewReject' }, + { value: 'PROMOTION_SUBMIT', labelKey: 'auditLog.filterPromotionSubmit' }, + { value: 'PROMOTION_APPROVE', labelKey: 'auditLog.filterPromotionApprove' }, + { value: 'PROMOTION_REJECT', labelKey: 'auditLog.filterPromotionReject' }, + { value: 'REPORT_SKILL', labelKey: 'auditLog.filterReportSkill' }, + { value: 'RESOLVE_SKILL_REPORT', labelKey: 'auditLog.filterResolveSkillReport' }, + { value: 'DISMISS_SKILL_REPORT', labelKey: 'auditLog.filterDismissSkillReport' }, + { value: 'HIDE_SKILL', labelKey: 'auditLog.filterHideSkill' }, + { value: 'ARCHIVE_SKILL', labelKey: 'auditLog.filterArchiveSkill' }, + { value: 'UNHIDE_SKILL', labelKey: 'auditLog.filterUnhideSkill' }, + { value: 'UNARCHIVE_SKILL', labelKey: 'auditLog.filterUnarchiveSkill' }, + { value: 'YANK_SKILL_VERSION', labelKey: 'auditLog.filterYankVersion' }, +] as const + export function AuditLogPage() { const { t, i18n } = useTranslation() const [actionFilter, setActionFilter] = useState('') @@ -56,13 +76,11 @@ export function AuditLogPage() { setActionFilter(e.target.value) setPage(0) }} className="w-[200px]"> - - - - - - - + {ACTION_OPTIONS.map((option) => ( + + ))}
{t('dashboard.governanceTitle')}
- - {t('dashboard.viewPromotions')} + + {t('dashboard.viewGovernance')}
{governanceVisible ? ( diff --git a/web/src/pages/dashboard/governance.tsx b/web/src/pages/dashboard/governance.tsx new file mode 100644 index 00000000..f2d6d4a6 --- /dev/null +++ b/web/src/pages/dashboard/governance.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { Card } from '@/shared/ui/card' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs' +import { GovernanceInbox } from '@/features/governance/governance-inbox' +import { GovernanceActivity } from '@/features/governance/governance-activity' +import { GovernanceNotifications } from '@/features/governance/governance-notifications' +import { + useGovernanceActivity, + useGovernanceInbox, + useGovernanceNotifications, + useGovernanceSummary, + useMarkGovernanceNotificationRead, +} from '@/features/governance/use-governance' + +type GovernanceInboxTab = 'ALL' | 'REVIEW' | 'PROMOTION' | 'REPORT' + +function SummaryCard({ label, value }: { label: string; value?: number }) { + return ( + +
{label}
+
{value ?? 0}
+
+ ) +} + +export function GovernancePage() { + const { t } = useTranslation() + const [inboxType, setInboxType] = useState('ALL') + const { data: summary, isLoading: isSummaryLoading } = useGovernanceSummary() + const { data: inboxItems, isLoading: isInboxLoading } = useGovernanceInbox(inboxType === 'ALL' ? undefined : inboxType) + const { data: activityItems, isLoading: isActivityLoading } = useGovernanceActivity() + const { data: notifications, isLoading: isNotificationsLoading } = useGovernanceNotifications() + const markReadMutation = useMarkGovernanceNotificationRead() + + const unreadCount = notifications?.filter((item) => item.status === 'UNREAD').length ?? 0 + + return ( +
+ + +
+ + + + +
+ + +
+

{t('governance.inboxTitle')}

+

{t('governance.inboxSubtitle')}

+
+ + setInboxType(value as GovernanceInboxTab)}> + + {t('governance.tabAll')} + {t('governance.tabReview')} + {t('governance.tabPromotion')} + {t('governance.tabReport')} + + + + + + + + + + + + + + +
+ +
+ +
+

{t('governance.notificationsTitle')}

+

{t('governance.notificationsSubtitle')}

+
+ markReadMutation.mutate(id)} + isMarkingRead={markReadMutation.isPending} + /> +
+ + +
+

{t('governance.activityTitle')}

+

{t('governance.activitySubtitle')}

+
+ +
+
+
+ ) +} diff --git a/web/src/pages/dashboard/reports.tsx b/web/src/pages/dashboard/reports.tsx index 1b25e3e5..a513307e 100644 --- a/web/src/pages/dashboard/reports.tsx +++ b/web/src/pages/dashboard/reports.tsx @@ -8,11 +8,17 @@ import { Button } from '@/shared/ui/button' import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { useDismissSkillReport, useResolveSkillReport, useSkillReports } from '@/features/report/use-skill-reports' import { toast } from '@/shared/lib/toast' +import type { ReportDisposition } from '@/api/types' export function ReportsPage() { const { t, i18n } = useTranslation() const navigate = useNavigate() - const [pendingAction, setPendingAction] = useState<{ id: number; action: 'resolve' | 'dismiss'; skillLabel: string } | null>(null) + const [pendingAction, setPendingAction] = useState<{ + id: number + action: 'resolve' | 'dismiss' + disposition?: ReportDisposition + skillLabel: string + } | null>(null) const { data: pendingReports, isLoading: isPendingLoading } = useSkillReports('PENDING') const { data: resolvedReports, isLoading: isResolvedLoading } = useSkillReports('RESOLVED') const { data: dismissedReports, isLoading: isDismissedLoading } = useSkillReports('DISMISSED') @@ -34,8 +40,8 @@ export function ReportsPage() { } try { if (pendingAction.action === 'resolve') { - await resolveMutation.mutateAsync({ id: pendingAction.id }) - toast.success(t('reports.resolveSuccessTitle'), t('reports.resolveSuccessDescription', { skill: pendingAction.skillLabel })) + await resolveMutation.mutateAsync({ id: pendingAction.id, disposition: pendingAction.disposition }) + toast.success(resolveSuccessTitle(pendingAction.disposition, t), resolveSuccessDescription(pendingAction.disposition, t, pendingAction.skillLabel)) } else { await dismissMutation.mutateAsync({ id: pendingAction.id }) toast.success(t('reports.dismissSuccessTitle'), t('reports.dismissSuccessDescription', { skill: pendingAction.skillLabel })) @@ -43,7 +49,7 @@ export function ReportsPage() { setPendingAction(null) } catch (error) { toast.error( - pendingAction.action === 'resolve' ? t('reports.resolveErrorTitle') : t('reports.dismissErrorTitle'), + pendingAction.action === 'resolve' ? resolveErrorTitle(pendingAction.disposition, t) : t('reports.dismissErrorTitle'), error instanceof Error ? error.message : '', ) } @@ -101,12 +107,28 @@ export function ReportsPage() { {t('reports.dismiss')} + +
) : (
@@ -151,11 +173,41 @@ export function ReportsPage() { }} title={pendingAction?.action === 'resolve' ? t('reports.resolveConfirmTitle') : t('reports.dismissConfirmTitle')} description={pendingAction?.action === 'resolve' - ? t('reports.resolveConfirmDescription', { skill: pendingAction?.skillLabel ?? '' }) + ? resolveConfirmDescription(pendingAction?.disposition, t, pendingAction?.skillLabel ?? '') : t('reports.dismissConfirmDescription', { skill: pendingAction?.skillLabel ?? '' })} - confirmText={pendingAction?.action === 'resolve' ? t('reports.resolve') : t('reports.dismiss')} + confirmText={pendingAction?.action === 'resolve' ? resolveConfirmText(pendingAction?.disposition, t) : t('reports.dismiss')} onConfirm={handleConfirm} />
) } + +function resolveConfirmText(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string) { + if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHide') + if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchive') + return t('reports.resolve') +} + +function resolveConfirmDescription(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string, skillLabel: string) { + if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideConfirmDescription', { skill: skillLabel }) + if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveConfirmDescription', { skill: skillLabel }) + return t('reports.resolveConfirmDescription', { skill: skillLabel }) +} + +function resolveSuccessTitle(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string) { + if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideSuccessTitle') + if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveSuccessTitle') + return t('reports.resolveSuccessTitle') +} + +function resolveSuccessDescription(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string, skillLabel: string) { + if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideSuccessDescription', { skill: skillLabel }) + if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveSuccessDescription', { skill: skillLabel }) + return t('reports.resolveSuccessDescription', { skill: skillLabel }) +} + +function resolveErrorTitle(disposition: ReportDisposition | undefined, t: (key: string, options?: any) => string) { + if (disposition === 'RESOLVE_AND_HIDE') return t('reports.resolveAndHideErrorTitle') + if (disposition === 'RESOLVE_AND_ARCHIVE') return t('reports.resolveAndArchiveErrorTitle') + return t('reports.resolveErrorTitle') +} diff --git a/web/src/shared/components/user-menu.tsx b/web/src/shared/components/user-menu.tsx index 5b4fa430..ac5ea17b 100644 --- a/web/src/shared/components/user-menu.tsx +++ b/web/src/shared/components/user-menu.tsx @@ -77,6 +77,11 @@ export function UserMenu({ user, triggerClassName }: UserMenuProps) { {t('user.menu.myNamespaces')} + + + {t('user.menu.governance')} + + {t('user.menu.stars')} diff --git a/web/src/shared/ui/tabs.tsx b/web/src/shared/ui/tabs.tsx index f441a737..cea6202a 100644 --- a/web/src/shared/ui/tabs.tsx +++ b/web/src/shared/ui/tabs.tsx @@ -5,6 +5,7 @@ interface TabsProps { defaultValue: string children: React.ReactNode className?: string + onValueChange?: (value: string) => void } interface TabsContextValue { @@ -14,11 +15,16 @@ interface TabsContextValue { const TabsContext = React.createContext(undefined) -export function Tabs({ defaultValue, children, className }: TabsProps) { +export function Tabs({ defaultValue, children, className, onValueChange }: TabsProps) { const [value, setValue] = React.useState(defaultValue) + const handleValueChange = React.useCallback((nextValue: string) => { + setValue(nextValue) + onValueChange?.(nextValue) + }, [onValueChange]) + return ( - +
{children}
)